Introduction
pdfboss is a PDF engine written from scratch in safe Rust against the ISO 32000
specification. It is a clean-room implementation: no C dependencies, no
bindings to another engine, and the image and color codecs a PDF needs
(JPEG 2000, JBIG2, CCITT, ICC) are its own. One core sits behind every
surface: the pdfboss command-line tool, an interactive terminal explorer
(pdfboss tui), a set of Rust library crates, and a native Python extension.
The surfaces expose the same engine at different altitudes. The CLI covers
extraction, rendering, creation and explorer subcommands over a document's
structure; the terminal explorer browses a file interactively, local or
remote. Python gets Document and its async twin AsyncDocument, with
pages, styled spans, lazy element iteration, rendering and image extraction,
plus document creation through pdfboss.write and pdfboss.md.to_pdf.
The Rust crates split the core by concern, from parsing (pdfboss-core)
through layout analysis (pdfboss-output) and rasterization
(pdfboss-render) to creation (pdfboss-write).
Leniency
Real-world PDFs are damaged: truncated downloads, editors that miscount stream lengths, generators that write cross-reference tables pointing nowhere. pdfboss reads them anyway:
- A broken or missing cross-reference table is reconstructed by scanning the file for its objects.
- A stream whose declared length is wrong is still decoded.
- Content-stream operators that will not parse are skipped, and the rest of the page still extracts and rasterizes.
Leniency never hides what it cost. Every dropped or approximated piece of
content lands in a report: pdfboss render warns on stderr, the terminal
explorer raises a notice, and the libraries return the report as a value,
through render_page_reporting and extract_text_reporting in Rust and
Page.render_reporting() in Python. A page that came out exactly as the file
describes it carries an empty report, so silence means fidelity, not luck.
Scope
pdfboss extracts plain text and Markdown (headings, lists and tables inferred
from page layout), yields styled text spans carrying position, font, weight,
decorations and color, rasterizes pages to PNG through its own JPEG 2000,
JBIG2, CCITT and ICC codecs, extracts embedded images at their native pixel
size, creates new PDFs (canvas painting and element composition through the
pdfboss-write crate, the pdfboss.write Python module and pdfboss create,
a TOML manifest form included, plus CommonMark+GFM composed into CSS-themed
documents from the CLI, Python pdfboss.md.to_pdf and Rust), and reads documents
asynchronously over range-fetching I/O, from local files or HTTP, without
ever reading the whole file.
Encrypted files open through the standard security handler: RC4 and AES-128/256, with either the user or the owner password; a file whose user password is empty opens without one.
Where to go next
Installation covers the wheel, the binary and the crates; the Quickstart shows each surface doing real work. The guide then takes one task per chapter:
- Extracting text: plain text, reading order, page selection.
- Markdown output: headings, lists and tables inferred from layout.
- Styled spans: positioned text runs with font, size, weight, decorations and color.
- Rendering pages: PNG output, scale, font tiers, render reports.
- Extracting images: every image a page draws, at native size.
- Creating PDFs: canvas painting, composed elements and document slots from the CLI, Python and Rust.
- Markdown to PDF: CommonMark+GFM composed into themed, paginated documents.
- Async and remote documents: range-fetching access to local files and HTTP URLs.
- Exploring PDF internals: the JSON value tree, jq queries, hexdumps and the terminal explorer.
- Encrypted documents: passwords on every surface.
The reference section holds the CLI reference, the Python API, the Rust crates and the list of limitations.
pdfboss is dual-licensed under MIT or Apache-2.0, at your option.
Installation
Python
pip install pdfboss
Prebuilt abi3 wheels for CPython 3.12 and later; no Rust toolchain required. The wheel compiles in the predefined CJK CMap set, so CJK-coded documents extract out of the box.
Rendering with fonts="full" substitutes replacement faces for fonts the PDF
does not embed. Those faces ship as a separate package, pulled in by the
full extra:
pip install "pdfboss[full]"
This installs pdfboss-fonts alongside the wheel. Without it, fonts="full"
requires a font_dir argument pointing at faces of your own. See
Rendering pages.
CLI
cargo install pdfboss-cli
This installs the pdfboss binary. Two features are on by default:
substitute-fonts: bundles the OFL Croscore substitute faces (about 4 MB) sorenderandtuican paint text for PDFs with non-embedded fonts out of the box.predefined-cmaps: compiles in the predefined CJK CMap set of ISO 32000 Table 118 (about 830 KB) sotextreads Shift-JIS/EUC/Big5/GBK/UHC-coded Type0 fonts.
Opt out of both for a leaner binary:
cargo install pdfboss-cli --no-default-features
Rust crates
The library crates are on crates.io:
cargo add pdfboss-core pdfboss-text pdfboss-output pdfboss-render pdfboss-write pdfboss-markdown pdfboss-aio
| Crate | Responsibility |
|---|---|
pdfboss-core | Parsing, object model, stream filters, document and page tree |
pdfboss-text | Fonts, encodings, positional text spans |
pdfboss-output | Layout analysis to plain text and Markdown |
pdfboss-render | Rasterization to RGBA pixmaps and PNG, embedded-image extraction |
pdfboss-write | PDF creation |
pdfboss-markdown | CommonMark+GFM composed into themed PDFs (pulls in pdfboss-style) |
pdfboss-aio | Async, range-fetching document access |
Add only what you use: pdfboss-core alone parses; the others build on it.
The library crates keep their optional features off by default:
pdfboss-rendersubstitute-fonts: the bundled substitute faces.pdfboss-corepredefined-cmaps: the predefined CJK CMap set.pdfboss-aiohttp: remote documents over HTTP range requests.pdfboss-aiowrite: streaming created documents into tokio writers; shipsTokioSink, which presents anytokio::io::AsyncWritetoPdf::write_into_with(see Creating PDFs).
Enable a feature at add time, for example:
cargo add pdfboss-aio --features http
Building from source
git clone https://github.com/4thel00z/pdfboss
cd pdfboss
cargo build --release # the CLI lands at target/release/pdfboss
cargo test --workspace # Rust test suite
The Python extension builds with maturin into the active virtualenv:
maturin develop # build the extension into your venv
pytest # Python integration tests
Next: the Quickstart.
Quickstart
One taste of each surface. report.pdf stands in for any PDF of yours;
Installation covers getting the binary, the wheel and the
crates.
CLI
pdfboss info report.pdf # version, page count, page sizes, metadata
pdfboss text report.pdf --page 2 # extract text (omit --page for all pages)
pdfboss md report.pdf # markdown: headings, lists, tables from layout
pdfboss render report.pdf --page 1 -o page.png --scale 2.0
mkdir out
pdfboss images report.pdf -o out/ # embedded images as native-size PNGs
pdfboss create text notes.txt -o notes.pdf # a new PDF from a word-wrapped text file
pdfboss create md notes.md -o notes.pdf # markdown composed with a CSS theme
render prints what it wrote (wrote page.png (1224 x 1584 px)) and warns on
stderr about anything it had to drop; images names every file it writes,
out/page-1-image-1.png style. Page numbers are 1-based on the command line.
Python
from pathlib import Path
import pdfboss
doc = pdfboss.Document("report.pdf")
print(doc.page_count, "pages, PDF", doc.version)
text = doc.extract_text() # all pages, form-feed separated
markdown = doc.extract_markdown() # headings, lists, tables from layout
page = doc[0]
Path("page.png").write_bytes(page.render(scale=2.0))
for image in page.extract_images():
print(image.width, image.height, len(image.data))
pdf = pdfboss.md.to_pdf(Path("notes.md").read_text()) # markdown -> PDF bytes
Document also opens from memory (Document(data=raw_bytes)), pages index
0-based with negative indexes from the end, and render returns PNG bytes
directly. extract_images yields each image the page draws at its native
pixel size, PNG-encoded. pdfboss.md.to_pdf composes CommonMark+GFM into a
themed PDF (Markdown to PDF); pdfboss.write composes
pages, elements and document slots with |, the same vocabulary the CLI and
the pdfboss-write crate expose (Creating PDFs).
Rust
With pdfboss-core, pdfboss-output and pdfboss-render added:
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
println!("{} pages", doc.page_count());
let page = doc.page(0)?;
let text = pdfboss_output::extract_text(&doc, &page)?;
println!("{text}");
let pixmap = pdfboss_render::render_page(&doc, &page, 2.0)?;
pixmap.save_png("page.png")?;
Ok(())
}
render_page returns an RGBA Pixmap (width, height, data); save_png
and encode_png turn it into a file or bytes. Page indexes are 0-based, as in
Python; only the CLI counts from 1.
Next steps
- Extracting text and Markdown output for layout analysis details.
- Rendering pages for scale, font tiers and PNG compression.
- Extracting images for what counts as an embedded image.
- Creating PDFs for canvas painting, composed pages and document slots.
- Markdown to PDF for themed CommonMark+GFM composition.
- Async and remote documents for
AsyncDocumentand HTTP range fetching.
Extracting text from PDFs
Text extraction turns a page's positioned glyphs back into readable text: spans are
grouped into lines, lines are joined with \n, and spaces are inserted at horizontal
gaps. Reading order follows the content stream: a typeset document writes each
column whole before the next begins, so the text comes out column by column, and a
figure caption or a footnote reads where the producer placed it. Geometry corrects
the streams that write across two columns row by row (a page with a clear gutter
still reads column-major) and takes over entirely when a stream was not written in
reading order at all, which then reads top to bottom. Whole-document extraction
joins pages with a form feed (\f). For structured output (headings, lists,
tables), see Markdown output; for the spans themselves, with
fonts, sizes and positions, see Styled spans.
CLI
pdfboss text report.pdf
prints every page, separated by form feeds. --page selects one page, 1-based:
pdfboss text --page 4 report.pdf
Content that cannot be read is reported on stderr, one warning per skipped stream, with the same 1-based page numbers:
warning: page 17: skipped a form XObject (form limit exceeded)
stdout carries only the extracted text, so the output stays safe to pipe. Encrypted
files take --password. See Encrypted documents.
Python
from pdfboss import Document
doc = Document("report.pdf")
text = doc.extract_text() # all pages, joined by "\f"
page_text = doc[3].extract_text() # one page (0-based index)
Document.extract_text fans the pages out across the machine's cores: each worker
thread holds its own fork of the document (the immutable parsed core is shared, the
caches are private), and one font cache serves every worker so each font loads once
per document. Both calls release the GIL while they run, so other Python threads
keep making progress during long extractions.
Rust
Per page, with pdfboss_output::extract_text:
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
for i in 0..doc.page_count() {
let page = doc.page(i)?;
let text = pdfboss_output::extract_text(&doc, &page)?;
println!("{text}");
}
Ok(())
}
For a whole document, pdfboss_core::map_pages runs a closure over every page in
parallel and returns the results in page order. It fans out over
std::thread::available_parallelism() threads, each holding its own document fork;
workers pull page indexes from a shared counter, so pages of uneven cost cannot
strand a fast core behind a slow stripe. Pass one FontCache to the _cached
variant so fonts load once per document rather than once per page:
use pdfboss_core::{map_pages, Document};
use pdfboss_output::FontCache;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let fonts = FontCache::default();
let outcomes = map_pages(&doc, |doc, page| {
let (text, _) = pdfboss_output::extract_text_reporting_cached(doc, page, &fonts)?;
Ok(text)
});
let texts = outcomes.into_iter().collect::<Result<Vec<String>, _>>()?;
println!("{}", texts.join("\u{c}"));
Ok(())
}
Asynchronous callers use extract_text_with against any object source. See
Async and remote documents.
Lenient semantics and reporting
Extraction is lenient the way rendering is: content that will not fetch, decode, or
parse yields no text rather than an error, so one unreadable stream never costs the
rest of the document. extract_text_reporting is what keeps that leniency
accountable. It returns the text together with an ExtractReport:
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let (text, report) = pdfboss_output::extract_text_reporting(&doc, &page)?;
for skip in &report.skipped {
eprintln!("skipped {} ({})", skip.kind, skip.cause);
}
println!("{text}");
Ok(())
}
report.skipped names each stream that yielded no text: the kind (the page's own
contents, a form XObject, an unresolvable XObject, a font's CMap encoding) and the
cause (an unsupported filter, a stream that would not read, content that would not
parse, a missing resource, an exhausted form nesting or invocation limit).
report.hidden counts
content the document's optional-content configuration turns off; that is configured
behavior, not a loss, so report.is_complete() ignores it and is true exactly when
nothing was left out. An empty text with an empty report really is an empty page.
The CLI warnings above are this report, printed. Layers the document's default
optional-content configuration disables are excluded from the text.
Encodings
Character decoding covers ToUnicode CMaps, the WinAnsi, MacRoman and Standard
simple-font encodings, the built-in encoding of an embedded Type 1 program (the base
table of a simple font that names no /Encoding of its own, which is how TeX's
symbol and math fonts arrive), and CID-keyed Type0 fonts: embedded /Encoding CMap
streams parse, the predefined ISO 32000 CJK CMap set is compiled in (behind the
predefined-cmaps feature, on by default in the CLI and the Python wheel), and when
/ToUnicode is absent CIDs map to Unicode through the font's character collection.
Glyph names resolve through the full Adobe Glyph List plus the TeX symbol-font names
the list lacks, so ligatures (fi, fl, …), small-caps variants and math symbols
(∀, ∃, ↦, ′) decode to their proper Unicode text.
PDF to Markdown
Markdown output runs layout analysis over the same positioned spans that plain text extraction flattens, and renders the result as CommonMark. The analysis infers:
- Headings: ATX headings (
#…######), ranked by font size. With whole-document extraction the sizes are ranked against every page at once, so a title page or a chapter opener (all of it larger than body text) is read as headings rather than as its own idea of body size. Page-local extraction ranks against that page alone; a page whose text is all one size has no heading to find. Prefer document-wide extraction whenever the document is at hand. - Lists: bulleted and numbered. Bullets render as
-regardless of the source glyph; numbered items keep their detected number asn.. - Tables: detected both from column gaps and from drawn borders, so bordered grids and boxed lists without column gaps are found too. When a table's structure is drawn as ruled lines, those borders decide the grid ahead of column occupancy. Tables render as pipe tables while every cell stands in one column, and as HTML tables as soon as a cell spans several.
- Two-column pages: read column-major: the left column top to bottom, then the right.
- Page headers, footers and page numbers: a page's first or last line, repeated near-verbatim at the same height on at least half the pages (three at minimum), is tagged as a running page header or footer; a line that is nothing but a page number is tagged without any repetition required. Tagged lines are dropped from the Markdown output.
CLI
pdfboss md report.pdf
--page extracts one page, 1-based; heading sizes are then ranked per page rather
than across the document:
pdfboss md --page 4 report.pdf
Warnings for skipped content appear on stderr, exactly as for pdfboss text. See
lenient semantics.
Before and after
Page 4 of a physics report carries a cut-flow table. Plain text extraction flattens it into space-separated lines:
Table 4: Muon RD Branch no. 1
Cuts km2loose acc km2tight acc bipulkm2acc acc
BADRUN 5189813 (-) 5189813 (-) - (-)
ictime − cktbm 3324048 (-) 3324048 (-) - (-)
icbit 3322245 (-) 3322245 (-) - (-)
pdfboss md --page 4 report.pdf recovers the grid (first rows shown):
Table 4: Muon RD Branch no. 1
| Cuts | km2loose acc | km2tight acc | bipulkm2acc acc |
| --- | --- | --- | --- |
| BADRUN | 5189813 (-) | 5189813 (-) | - (-) |
| ictime − cktbm | 3324048 (-) | 3324048 (-) | - (-) |
| icbit | 3322245 (-) | 3322245 (-) | - (-) |
Python
from pdfboss import Document
doc = Document("report.pdf")
markdown = doc.extract_markdown() # whole document, headings ranked globally
page_md = doc[3].extract_markdown() # one page, headings ranked per page
Document.extract_markdown extracts the pages in parallel and releases the GIL,
like extract_text.
Rust
Whole document, with pdfboss_output::extract_markdown:
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let markdown = pdfboss_output::extract_markdown(&doc)?;
println!("{markdown}");
Ok(())
}
One page, with extract_page_markdown:
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let markdown = pdfboss_output::extract_page_markdown(&doc, &page)?;
println!("{markdown}");
Ok(())
}
extract_markdown_reporting returns the Markdown together with one
ExtractReport per page, in page order (the same accountability contract as text
extraction): unreadable content costs its own text and nothing else, and the report
says what was left out. See
lenient semantics for the report's
shape. Asynchronous callers compose extract_page_markdown_with against any object
source. See Async and remote documents.
Emphasis survives into the output: bold and italic runs render as **bold** and
*italic* inside paragraphs and list items. Headings drop emphasis markers: a
heading is already the strongest thing on the page. Blocks are separated by a blank
line, across page boundaries too, so the document reads as one continuous Markdown
file. For the raw style information itself, see Styled spans; for the
reverse direction (Markdown composed into a PDF), see
Markdown to PDF.
Styled spans
Text extraction gives you a page as flowing plain text. Spans are the layer underneath: each span is one positioned run of text together with everything the file states about how it is shown (position, size, font, weight, color, visibility). Use spans when you need to know not just what a page says but where and in what style: finding headings, separating an OCR layer from printed text, or feeding a layout analysis of your own.
What a span carries
| Property | Meaning |
|---|---|
text | The decoded text. |
x, y | Device-space origin and baseline of the span. |
end_x | Device-space x after the last glyph's advance. |
size | Effective font size. |
font | Font resource name (e.g. "F1"). |
font_name | The font's /BaseFont name verbatim, subset prefix included (e.g. "NZEVTB+Arial-BoldItalicMT"); empty when the file names the font nowhere. |
page | 0-based index of the page the span came from. |
bbox | Device-space box (x0, y0, x1, y1), y-up: origin to advance horizontally, the font's descent..ascent vertically. |
bold, italic | From FontDescriptor evidence, falling back to the /BaseFont name. |
monospace, serif | FontDescriptor /Flags FixedPitch and Serif. |
underline, strikethrough | A drawn ruling below the baseline / across the x-height band. See the caveat below. |
rise | The text rise (Ts) the span was shown under: positive above the baseline, a superscript/subscript signal. |
vertical | Writing mode 1: the text advances downward. |
invisible | Shown under render mode 3 or 7, which paint nothing. |
color | Fill color as RGB in [0, 1]; None for pattern fills. |
Three of these deserve honesty up front:
underlineandstrikethroughare read from the page's geometry. PDF has no underline attribute; a span is underlined when a drawn ruling sits just below its baseline covering most of it. A table border hugging a cell's text can read as an underline.invisibleis the signature of an OCR text layer. Scanned PDFs with a text layer draw the page image and then show the recognized text under render mode 3 or 7, which paint nothing. The text extracts normally: it is just never painted.colorisNonefor pattern fills, which have no single color.
Python
Page.spans() returns the page's spans in emission order. It releases the GIL
while it runs and is lenient the same way text extraction is: unreadable
content yields no spans rather than raising.
import pdfboss
doc = pdfboss.Document("report.pdf")
for span in doc[0].spans():
if not span.bold:
continue
print(f"{span.size:5.1f}pt {span.font_name:30s} {span.text!r}")
Document.spans() iterates the whole document lazily, page by page: it
buffers one page's spans at a time, extracts each page with the GIL released,
and shares one font cache across the walk, so a font used on every page loads
once. Pass pages=[...] (0-based) to restrict the walk, in the order given.
Finding headings (bold text larger than the document's body size) is a document-level walk:
from collections import Counter
import pdfboss
doc = pdfboss.Document("report.pdf")
sizes: Counter[int] = Counter()
bold = []
for span in doc.spans():
sizes[round(span.size)] += len(span.text)
if span.bold:
bold.append(span)
body = sizes.most_common(1)[0][0]
for span in bold:
if span.size <= body:
continue
print(f"page {span.page + 1}: {span.size:.0f}pt {span.text}")
Detecting an OCR layer is a one-liner over invisible:
spans = doc[0].spans()
ocr = [span for span in spans if span.invisible]
print(f"{len(ocr)} of {len(spans)} spans are invisible (an OCR text layer)")
Both have async twins, await page.spans() and async for span in doc.spans(), described in Async and remote documents.
Rust
pdfboss_text::extract_spans returns a Vec<TextSpan> carrying the same
fields as the Python Span (as plain struct fields: text, x, y,
end_x, size, font, font_name, page, bbox, bold, italic,
monospace, serif, rise, vertical, invisible, color, underline,
strikethrough):
use pdfboss_core::Document;
use pdfboss_text::extract_spans;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
for index in 0..doc.page_count() {
let page = doc.page(index)?;
for span in extract_spans(&doc, &page)? {
if !span.bold || span.size <= 12.0 {
continue;
}
println!("page {}: {:.0}pt {}", span.page + 1, span.size, span.text);
}
}
Ok(())
}
The crate also offers extract_spans_reporting (an ExtractReport naming
each stream that could not be read: an empty span list with an empty report
really is an empty page), extract_spans_reporting_cached (one FontCache
shared across a whole-document walk, the same trick Document.spans() uses),
and extract_spans_and_rulings_reporting, which additionally returns the
page's Ruling segments, the drawn lines the underline and strikethrough
flags are derived from.
Spans are the input to the layout analysis behind Markdown output; reach for that chapter when you want headings, lists and tables inferred for you rather than deriving them from spans yourself.
Rendering PDF pages to PNG
pdfboss rasterizes pages to RGBA pixels with anti-aliasing and encodes them as
PNG, PPM, BMP or JPEG. The scale factor is the points-to-pixels ratio: at 1.0 one PDF point
becomes one pixel, so a US Letter page renders to 612 × 792 pixels; at 2.0
to 1224 × 1584. The pixel size is ceil(crop_w * scale) × ceil(crop_h * scale) after page rotation, on a white background.
This chapter is about rasterizing whole pages. To pull out the images a page embeds, at their native resolution, see Extracting images.
CLI
pdfboss render report.pdf --page 1
pdfboss render report.pdf --page 1 --scale 2 -o page-1@2x.png
pdfboss render report.pdf --page 1 --scale 2 -o page-1.ppm
The first form writes page-1.png (--page is 1-based). Further flags:
-o <OUT>: the output file; its extension picks the format,.png,.ppm,.bmpor.jpg(see Output formats). Any other extension is an error.--fonts <FONTS>: which fonts to paint, one ofembedded-only,all-embeddedorfull(see the tiers below). The default resolves tofullwhen substitute faces are available (the compiled-in OFL set or--font-dir), otherwiseall-embedded.--font-dir <FONT_DIR>: a directory of substitute faces for--fonts full(e.g. an installedpdfboss-fontspackage), named as listed under Substitute face files. Overrides the compiled-in OFL set.--png-compression <PNG_COMPRESSION>:none,fast,defaultorbest; PNG only.--jpeg-quality <1-100>: the JPEG quality (default 90); JPEG only.--password <PASSWORD>: for encrypted files, covered in Encrypted documents.
Anything the render dropped is warned on stderr.
Font tiers
Glyph painting is staged in tiers; each tier is a strict superset of the previous one.
embedded-onlypaints only embedded TrueType outlines: the fastest tier, and TrueType only, not every embedded font.all-embedded(the default when no substitute faces are available) paints every embedded font program: TrueType, CFF, Type1 and Type3.full(the default whenever substitute faces are available) additionally substitutes a replacement face for non-embedded simple fonts: from a directory you supply (--font-dir,font_dir=), or from the compiled-in OFL Croscore set (Arimo/Tinos/Cousine, metric-compatible with Helvetica/Times/Courier). In Python,pip install pdfboss[full]installs those faces as thepdfboss-fontspackage; with neitherfont_dirnor that package available,fonts="full"raisesValueError.
Text a tier leaves unpainted still advances (through the PDF's own /Widths,
or the Adobe Core-14 AFM tables for a standard-14 face), so everything painted
around it stays where the page put it. /Symbol and /ZapfDingbats have no
license-clean substitute and stay blank; see
Limitations.
Substitute face files
A substitute directory (--font-dir, font_dir=, SubstituteSource::Dir)
holds one file per face, looked up by these exact names:
| Family | Files |
|---|---|
| Sans (Arimo) | Arimo[wght].ttf, Arimo-Italic[wght].ttf (variable files, rendered at their default instance), Arimo-Bold.ttf, Arimo-BoldItalic.ttf (static bold instances) |
| Serif (Tinos) | Tinos-Regular.ttf, Tinos-Bold.ttf, Tinos-Italic.ttf, Tinos-BoldItalic.ttf |
| Mono (Cousine) | Cousine-Regular.ttf, Cousine-Bold.ttf, Cousine-Italic.ttf, Cousine-BoldItalic.ttf |
A file that is missing or unreadable means no substitution for the faces that map to it; nothing else in the directory is read.
Leniency and reporting
Rendering never fails because one construct in a page would not read: content
pdfboss cannot fetch, decode or parse is skipped and the rest of the page
still rasterizes. The honest consequence is that a page can come back blank
without an error. The reporting variants return what was dropped or
approximated, one line per distinct loss, for example
"153 glyphs skipped: no glyph for code 9 in /MBIPWP+Times-Roman" or
"6 annotations skipped: the resource is missing". An empty report means the
page rasterized exactly as it describes itself.
Two things are deliberately not reported, because they are configured behavior
rather than a failure: text left unpainted by the requested font tier, and
content in optional-content layers (PDF layers) the document's default
configuration turns off. The latter is counted separately, on the report's
hidden counter in Rust.
Python
Page.render returns PNG bytes. scale must be positive and finite
(ValueError otherwise).
from pathlib import Path
import pdfboss
doc = pdfboss.Document("report.pdf")
page = doc[0]
png = page.render(scale=2.0)
Path("page-1.png").write_bytes(png)
render accepts fonts= ("embedded-only", "all-embedded", "full"),
font_dir=, compression= ("none", "fast", "default", "best"),
format= ("png", "ppm", "bmp", "jpeg", see
Output formats) and quality= (1 to 100, JPEG only),
and releases the GIL while it runs. fonts= defaults to None, which
resolves to "full" when font_dir= is given or the pdfboss-fonts
package is importable, and to "all-embedded" otherwise. Page.render_reporting renders the same
way and returns (png, warnings):
png, warnings = page.render_reporting()
for line in warnings:
print(line)
Document.render_pages renders many pages fanned out across the machine's
cores: every page by default, or the 0-based pages given, returned in the
order given.
pngs = doc.render_pages(scale=2.0)
first_two_reversed = doc.render_pages(pages=[1, 0])
The full signature is render_pages(pages=None, scale=1.0, fonts=None, font_dir=None, compression="default", format="png", quality=90); fonts,
font_dir, compression, format and quality mean the same as on
Page.render, applied to every page, and a fonts of None resolves the
same way. The stub file
_pdfboss.pyi
documents each parameter.
All three have async twins on AsyncPage/AsyncDocument, which also render
documents opened over HTTP. See
Async and remote documents.
Output formats
Every render entry point writes one of four formats, chosen by format=
in Python, by the -o extension on the CLI, and by
pdfboss_render::ImageFormat in Rust:
| format | pixels | what it costs |
|---|---|---|
png (default) | RGBA 8-bit, filtered and deflated | the compression level below |
ppm | binary P6: P6 <w> <h> 255\n then RGB rows, top-down | one packing pass |
bmp | 24-bit BGR, bottom-up rows padded to four bytes, 54-byte header | one packing pass |
jpeg (jpg) | baseline JFIF, 4:4:4, lossy at quality 1 to 100 (default 90) | a DCT per 8×8 block |
PPM and BMP drop the alpha channel; a rendered page is filled white, so alpha is 255 everywhere and nothing is lost. Reach for them when the pixels are consumed right away (a benchmark, an OCR or vision pipeline, a diff against another renderer) and the PNG encode would be wasted work: Pillow, numpy and ImageMagick read both directly.
JPEG is the one lossy choice: it trades exact pixels for smaller files on photographic and scanned pages, with the quality knob scaling the standard quantization tables (50 uses them as printed, 100 keeps every coefficient). On a mostly white text page PNG stays both smaller and exact, so prefer JPEG only where the content is continuous-tone. Chroma is not subsampled, so colored text keeps sharp edges. The encoder is pdfboss's own, written from the JPEG specification, and costs two to three times the default PNG encode per page.
ppm = page.render(scale=2.0, format="ppm")
bmp, warnings = page.render_reporting(scale=2.0, format="bmp")
jpg = page.render(scale=2.0, format="jpeg", quality=80)
PNG compression
The compression level trades encode time against file size; every level
produces the same pixels. none is fastest and largest, fast is very fast
with a decent ratio, default balances the two, and best produces the
smallest files, much slower. The level only touches the PNG encoder; even
none still filters rows and writes checksums, so a raw-pixel consumer is
better served by ppm or bmp. Pick it by whether you are writing
throwaway intermediates or archiving.
Rust
pdfboss_render::render_page(doc, page, scale) returns a Pixmap: width,
height, and data holding width * height * 4 RGBA bytes (straight alpha,
row-major from the top-left). Pixmap::save_png writes it to a file;
encode_png/encode_png_with return the bytes, the latter taking a
PngCompression (None, Fast, Balanced or Best; Balanced is the
level the other surfaces call default).
render_page_with_options adds RenderOptions, a struct of four public
fields:
glyph_paintingselects theGlyphPaintingtier:EmbeddedTrueTypeOnly,AllEmbeddedorFull.substitutessays where theFulltier's replacement faces come from.SubstituteSource::Builtinis the compiled-in OFL set, present only when the crate is built with thesubstitute-fontsCargo feature; without that feature,Builtindegrades to no substitution, soFullbehaves exactly likeAllEmbedded. The probepdfboss_render::builtin_fonts_available()reports whether the compiled-in set exists.SubstituteSource::Dir(path)reads faces from a directory named as in Substitute face files. The defaultSubstituteSource::Nonesubstitutes nothing, soFullbehaves likeAllEmbeddeduntil you opt in.oc: Option<Arc<OcState>>is the document's optional-content visibility. The synchronous entry points fill it from the document when it isNone; an asynchronous caller builds it itself, fromAsyncDocument::oc_state, and leaving itNonethere renders every layer. See Async and remote documents.cache: Option<Arc<RenderCache>>shares oneRenderCacheacross a whole-document walk. It retains loaded fonts and parsedICCBasedcolorspace outcomes across pages;Nonekeeps every load page-local.
render_page_reporting returns the
RenderReport alongside the pixels; report.summary() is a one-line count
per kind, report.warnings() one line per distinct drop.
use pdfboss_core::Document;
use pdfboss_render::{
render_page, render_page_reporting, GlyphPainting, PngCompression, RenderOptions,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let pixmap = render_page(&doc, &page, 2.0)?;
pixmap.save_png("page-1.png")?;
let opts = RenderOptions {
glyph_painting: GlyphPainting::EmbeddedTrueTypeOnly,
..RenderOptions::default()
};
let (pixmap, report) = render_page_reporting(&doc, &page, 1.0, &opts)?;
if let Some(summary) = report.summary() {
eprintln!("{summary}");
}
for warning in report.warnings() {
eprintln!("{warning}");
}
let png = pixmap.encode_png_with(PngCompression::Best)?;
std::fs::write("page-1-small.png", png)?;
Ok(())
}
For a whole document, pass one RenderCache to every page, so each font
program and each ICCBased profile loads once per document rather than once
per page:
use std::sync::Arc;
use pdfboss_core::{map_pages, Document};
use pdfboss_render::{render_page_with_options, RenderCache, RenderOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let opts = RenderOptions {
cache: Some(Arc::new(RenderCache::default())),
..RenderOptions::default()
};
let outcomes = map_pages(&doc, |doc, page| {
render_page_with_options(doc, page, 2.0, &opts)
});
for (index, pixmap) in outcomes.into_iter().enumerate() {
pixmap?.save_png(format!("page-{}.png", index + 1))?;
}
Ok(())
}
Extracting images from PDFs
pdfboss extracts the images a page draws (photographs, scans, logos, figures),
each decoded at its own pixel dimensions and delivered as RGBA with alpha.
Three surfaces expose it: the pdfboss images CLI command,
Page.extract_images in Python, and pdfboss_render::extract_page_images in
Rust. To rasterize a whole page to a single PNG instead, see
Rendering pages.
What gets extracted
Extraction walks the page's content stream and collects an image at every point where one is drawn:
- Occurrence-based. The result reflects what the page draws, not what its resources contain. An image drawn twice appears twice; an image XObject listed in the resources but never drawn does not appear at all.
- Drawing order. Images come back in the order the content draws them.
- Form XObjects are followed. An image drawn inside a form (a reused header graphic, a stamped figure) is reached through the form, to the same bounded nesting depth the renderer uses, so a form that draws itself terminates instead of recursing forever.
- Inline images are included.
BI … ID … EIsequences embedded directly in the content stream extract like any image XObject. - Stencil masks are skipped. An image with
/ImageMask truepaints the current fill color through a 1-bit stencil; it carries no pixels of its own, so there is nothing to extract. /SMaskbecomes the alpha channel. An image's soft mask is merged into the output as straight (non-premultiplied) alpha.- Native size. Each image decodes at its own
/Width×/Height, never at render resolution or at the size it occupies on the page. A 3000 × 2000 photo scaled into a thumbnail box still comes out at 3000 × 2000. - Optional content is not consulted. An image inside a hidden
/OCgroup is still embedded in the file, so it still extracts. - Lenient. Content that cannot be read or decoded contributes nothing rather than failing the call, matching how rendering skips what it cannot draw.
Filtering small images
There is no hidden minimum-size filter: every drawn image comes back, including one-pixel spacers and thin decorative strips. Each result carries its native width and height, so callers apply their own threshold: the synchronous Python and Rust examples below skip anything under 100 × 100 pixels.
CLI
pdfboss images writes every image the selected pages draw as a PNG named
page-N-image-M.png, with both numbers 1-based and M counting in drawing
order within the page:
mkdir images
pdfboss images --page 6 -o images report.pdf
wrote images/page-6-image-1.png (137 x 178 px)
wrote images/page-6-image-2.png (750 x 989 px)
wrote images/page-6-image-3.png (132 x 177 px)
wrote images/page-6-image-4.png (216 x 295 px)
wrote images/page-6-image-5.png (144 x 168 px)
wrote images/page-6-image-6.png (50 x 120 px)
wrote images/page-6-image-7.png (165 x 211 px)
wrote images/page-6-image-8.png (145 x 188 px)
wrote images/page-6-image-9.png (165 x 206 px)
extracted 9 images
Without --page every page is processed. -o names the output directory
(default: the current directory); it must already exist. --png-compression
trades encode time against file size: none, fast, default or best,
all producing the same pixels. A page whose images cannot be decoded writes
nothing for them and still exits 0.
Python
Page.extract_images returns a list of PageImage objects, each holding the
PNG-encoded pixels as data plus the native width and height. Saving
every sufficiently large image in a document:
from pathlib import Path
import pdfboss
doc = pdfboss.Document("report.pdf")
out = Path("images")
out.mkdir(exist_ok=True)
for number, page in enumerate(doc, start=1):
for i, image in enumerate(page.extract_images(), start=1):
if image.width < 100 or image.height < 100:
continue
target = out / f"page-{number}-image-{i}.png"
target.write_bytes(image.data)
print(f"{target}: {image.width} x {image.height}")
The drawing-order index i is kept even for skipped images, so file names
stay aligned with what pdfboss images would produce. extract_images
accepts the same compression argument as render ("none", "fast",
"default", "best").
AsyncPage.extract_images is the async twin, with the same drawing-order,
native-size and leniency semantics (see
Async and remote documents):
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open("report.pdf")
page = doc.page(0)
images = await page.extract_images()
print(f"page 1 draws {len(images)} images")
asyncio.run(main())
Rust
pdfboss_render::extract_page_images returns Vec<Pixmap>: RGBA8 pixels
with straight alpha, row-major from the top-left, with public width,
height and data fields. Pixmap::save_png writes one to disk;
encode_png/encode_png_with produce the bytes in memory. With
pdfboss-core and pdfboss-render as dependencies:
use pdfboss_core::Document;
use pdfboss_render::extract_page_images;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
let page = doc.page(0)?;
let images = extract_page_images(&doc, &page)?;
for (i, image) in images.iter().enumerate() {
if image.width < 100 || image.height < 100 {
continue;
}
image.save_png(format!("image-{}.png", i + 1))?;
println!("image-{}.png: {} x {}", i + 1, image.width, image.height);
}
Ok(())
}
extract_page_images_with is the same walk over any
pdfboss_core::AsyncObjectSource. pdfboss_aio::AsyncDocument implements
that trait and is an Arc handle, so cloning one to pass by value is cheap
(with pdfboss-aio, pdfboss-render and tokio as dependencies):
use pdfboss_aio::AsyncDocument;
use pdfboss_render::extract_page_images_with;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open("report.pdf").await?;
for index in 0..doc.page_count() {
let page = doc.page(index)?;
let images = extract_page_images_with(doc.clone(), &page).await?;
for (i, image) in images.iter().enumerate() {
image.save_png(format!("page-{}-image-{}.png", index + 1, i + 1))?;
}
}
Ok(())
}
The async form works identically over a remote document opened with
AsyncDocument::open_url, fetching only the byte ranges the images need.
Full option listings live in the CLI reference.
Creating PDFs
The write side of pdfboss is the pdfboss-write Rust crate, the pdfboss.write Python module and the pdfboss create CLI. This chapter covers two altitudes: the canvas level, where you place every shape, glyph run and image yourself, and the element level one step above it, where Text, Paragraph, Image and Link values compose onto pages and document slots carry outlines, attachments, page labels and viewer preferences. Python reaches both: pdfboss.write composes elements and slots with |, and its draw protocol paints on the canvas directly. To compose a document from CommonMark+GFM source instead, from the CLI, Rust or Python (pdfboss.md.to_pdf), see Markdown to PDF. Everything the writer emits uses the same content-stream IR the reader parses, so a created file round-trips through the rest of the toolkit.
The document model
A document is plain data: Pdf { metadata, pages, outline, attachments, page_labels, viewer, options }. The fields are the composition: pages appear in the output in the order of the Vec, singleton slots are Options, sequences may stay empty, and Default fills everything optional. Each Page { size, rotation, canvas, content, links } carries operators painted directly on its canvas, composed elements in content (lowered onto the canvas at serialization time, after anything painted directly), and its clickable areas: a LinkAnnotation { rect, target } marks a rectangle in page user space, emitted as a /Link annotation under /Annots, whose LinkTarget is either Uri(String) (a /URI action) or Page(usize) (a /GoTo action with an explicit /XYZ null null null destination that keeps the viewer's current position and zoom).
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Hello from pdfboss", 72.0, 770.0, Standard14::Helvetica, 24.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
pdf.save("hello.pdf")?;
Ok(())
}
Coordinates are PDF user space: the origin is the bottom-left corner of the page, y grows upward, and one unit is 1/72 inch (a point). PageSize offers A3 (841.89 × 1190.55), A4 (595.28 × 841.89, the default), A5 (419.53 × 595.28), Letter (612 × 792), Legal (612 × 1008) and Custom { width, height }; dimensions() returns the pair, and landscape() swaps it into a Custom. rotation is a clockwise view rotation in degrees.
Serialization validates rather than guesses. A document with zero pages is an error, and so are a rotation that is not a multiple of 90, a link, bookmark or open_to target page out of range, a duplicate attachment name, a page-label set without a range at page 0 or with a duplicate first_page or a start_at of 0 (numbering starts at 1), and a paragraph whose wrapped lines overflow its rect.
Canvas
Canvas is an imperative painter. Path construction (move_to, line_to, curve_to, close) is separate from painting (fill, fill_even_odd, stroke, close_stroke, fill_stroke, end_path), exactly as in PDF content streams. Convenience shapes append complete subpaths: rect, circle and ellipse (four Bézier arcs), and polygon over a slice of pdfboss_core::Point (fewer than three points appends nothing).
Graphics state follows the same operators: save/restore push and pop, transform concatenates a pdfboss_core::Matrix onto the CTM, and set_line_width, set_line_cap, set_line_join, set_miter_limit and set_dash control stroking. clip and clip_even_odd intersect the clip region with the current path and consume it. Colors are device colors (Color::Gray(g), Color::Rgb(r, g, b), Color::Cmyk(c, m, y, k), components in 0.0..=1.0, with Color::BLACK and Color::WHITE constants), set independently for fill (set_fill) and stroke (set_stroke). For anything the methods do not cover, op pushes a raw pdfboss_core::content::Op.
Canvases nest and carry transparency state. group(canvas, bbox) registers a finished sub-canvas as a Form XObject and returns a GroupHandle; draw_group(handle, matrix) paints it under a matrix, and two calls with the same handle reference one form resource. set_fill_alpha, set_stroke_alpha and set_blend_mode each emit a gs operator over a deduplicated single-key /ExtGState entry (/ca, /CA and /BM respectively); BlendMode covers the twelve separable modes. The resource naming contract, which op callers must keep consistent, is fixed: fonts are F1, F2, …, images Im1, …, groups Gp1, …, graphics states Gs1, …. Fonts are deduplicated document-wide, nested groups included, but a canvas registered as a group on two pages produces two Form XObjects; cross-page group sharing is deferred.
Text
canvas.text(text, x, y, font, size) shows one line with its baseline origin at (x, y). The faces are the fourteen standard fonts every conforming reader provides, as Standard14 variants: Helvetica, HelveticaBold, HelveticaOblique, HelveticaBoldOblique, TimesRoman, TimesBold, TimesItalic, TimesBoldItalic, Courier, CourierBold, CourierOblique, CourierBoldOblique, Symbol, ZapfDingbats. No font program is embedded: readers carry these faces.
The twelve text faces encode as WinAnsi. A character outside the encoding is an error, never silently dropped or replaced, and the error is raised before any operator is pushed, leaving the canvas untouched. Symbol and ZapfDingbats have no encoding tables, so every character is an encoding error in those faces. The library does not wrap or lay out text: one call is one line; Standard14::text_width(text, size) returns a string's width from the AFM metrics (bare advance widths, no kerning) for callers doing their own layout.
Fonts are deduplicated document-wide: each distinct face gets one font object, in first-use order, no matter how many pages use it.
Images
ImageData imports or wraps pixels; add_image registers it on a canvas and draw_image(handle, x, y, width, height) paints it into an axis-aligned box:
ImageData::png(&bytes): decodes a PNG; truecolor and grayscale (16-bit reduced to 8), palette expanded, alpha split into a soft mask.ImageData::jpeg(&bytes): baseline or progressive JPEG by passthrough. The original bytes are embedded as/DCTDecode, dimensions sniffed from the SOF marker. Grayscale and three-component images only.ImageData::rgb8(w, h, data),gray8(w, h, data): 8-bit rasters,datalength checked against the dimensions.ImageData::mono(w, h, data): 1-bit rasters, rows packed MSB-first and byte-padded; a set bit is black.ImageData::decode(&bytes): dispatches on content rather than file extension. A PNG signature goes topng, a JPEG SOI marker tojpeg, anything else is an error.
Images are embedded per page with no cross-page deduplication: the same raster drawn on two pages is stored twice.
A complete page
One page with shapes, text in two faces, and a generated raster:
use pdfboss_core::Point;
use pdfboss_write::{Color, Date, ImageData, Metadata, Page, PageSize, Pdf, Standard14};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
let canvas = &mut page.canvas;
// A stroked frame with a dash pattern.
canvas.set_stroke(Color::Gray(0.3));
canvas.set_line_width(1.5);
canvas.set_dash(&[6.0, 3.0], 0.0);
canvas.rect(36.0, 36.0, 523.28, 769.89);
canvas.stroke();
canvas.set_dash(&[], 0.0);
// Filled shapes: a rectangle, a circle, a triangle.
canvas.set_fill(Color::Rgb(0.85, 0.2, 0.2));
canvas.rect(72.0, 600.0, 120.0, 80.0);
canvas.fill();
canvas.set_fill(Color::Rgb(0.2, 0.5, 0.85));
canvas.circle(280.0, 640.0, 45.0);
canvas.fill();
canvas.set_fill(Color::Cmyk(0.6, 0.0, 0.9, 0.1));
canvas.polygon(&[
Point::new(380.0, 600.0),
Point::new(500.0, 600.0),
Point::new(440.0, 690.0),
]);
canvas.fill();
// Text in two of the fourteen standard faces.
canvas.set_fill(Color::BLACK);
canvas.text("Quarterly report", 72.0, 540.0, Standard14::HelveticaBold, 28.0)?;
canvas.text(
"Generated with pdfboss-write.",
72.0,
510.0,
Standard14::TimesRoman,
12.0,
)?;
// A generated raster, embedded and drawn at 200 x 100 pt.
let mut pixels = Vec::with_capacity(64 * 32 * 3);
for y in 0..32u32 {
for x in 0..64u32 {
pixels.extend([(x * 4) as u8, (y * 8) as u8, 128]);
}
}
let gradient = ImageData::rgb8(64, 32, pixels)?;
let handle = canvas.add_image(gradient);
canvas.draw_image(handle, 72.0, 380.0, 200.0, 100.0);
let pdf = Pdf {
metadata: Some(Metadata {
title: Some("Quarterly report".into()),
author: Some("pdfboss".into()),
creation_date: Some(Date {
year: 2026,
month: 8,
day: 27,
hour: 12,
minute: 0,
second: 0,
utc_offset_minutes: 0,
}),
..Metadata::default()
}),
pages: vec![page],
..Pdf::default()
};
pdf.save("report.pdf")?;
Ok(())
}
To embed an existing file instead of a generated raster: ImageData::png(&std::fs::read("photo.png")?)?.
Composing pages
Page::content holds Content values, an element vocabulary one step above raw canvas operators: Text, Image, Link and Paragraph, each convertible with Content::from, plus Content::Custom(Box<dyn Draw>) via Content::custom for anything implementing Draw (fn draw(&self, canvas: &mut Canvas) -> Result<()>, plus Send). Elements lower onto the page's canvas at serialization time, in order, after any operators already painted there directly, so elements paint over manual canvas work, never under it. A Link element is the exception: it lowers into the page's links vector instead of painting.
Paragraph { text, rect, font, size, leading, align } wraps text into its rect. \n forces a line break, other whitespace runs between words collapse to one space, and a blank source line keeps its vertical advance. leading defaults to 1.2 * size; align is ParagraphAlign::Left, Center, Right or Justify, and justification stretches word spacing on every line except the last visible one. A paragraph that does not fit is an error naming how many lines fit and how many were needed, and an unencodable character errors exactly as in canvas.text.
Image { data, at, width, height } sizes itself from what is given: with both dimensions None it paints at the natural pixel size at 72 dpi, one given dimension scales the other by aspect, and both given paint the exact box.
use pdfboss_core::Point;
use pdfboss_write::{Content, Link, LinkTarget, Page, PageSize, Paragraph, Pdf, Standard14, Text};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.content.push(Content::from(Text {
value: "Q3 Report".into(),
at: Point::new(72.0, 770.0),
font: Standard14::HelveticaBold,
size: 28.0,
..Text::default()
}));
page.content.push(Content::from(Paragraph {
text: "Prepared for the board. Revenue, costs and outlook for the \
quarter, wrapped and aligned without manual line breaks."
.into(),
rect: [72.0, 600.0, 523.0, 740.0],
..Paragraph::default()
}));
page.content.push(Content::from(Link {
rect: [72.0, 60.0, 200.0, 80.0],
target: LinkTarget::Uri("https://example.com/q3".into()),
}));
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
pdf.save("q3.pdf")?;
Ok(())
}
Document slots
Beside pages, four Pdf fields carry document-level structure, each plain data and each optional.
Outline { bookmarks } is the viewer's bookmark panel: an ordered forest of Bookmark { title, page, children } nodes, Bookmark::new(title, page) building a leaf. Each bookmark targets a 0-based page index with an explicit /XYZ null null null destination, keeping the viewer's current position and zoom.
Attachment { name, data, mime, modified, description } embeds a file via the catalog's /Names /EmbeddedFiles name tree. name becomes the filespec's /F and /UF and the name-tree key; data is stored as the embedded-file stream, compressed per WriteOptions::compress; mime is the stream's /Subtype, defaulting to application/octet-stream; modified and description write /Params /ModDate and /Desc only when given. Attachments are reordered bytewise by name at emission, since the name tree's keys must be sorted, so the order given is not preserved; a duplicate name is an error.
page_labels holds PageLabel { first_page, style, prefix, start_at } ranges controlling how viewers display page numbers. A range takes effect from first_page (0-based) until the next range or the document's end. LabelStyle is Decimal, RomanUpper, RomanLower, LettersUpper or LettersLower, written as /S D, R, r, A or a; prefix prepends text to every number in the range, and /St is written only when start_at is not 1. Ranges are reordered by first_page, and a non-empty set must include a range starting at page 0.
Viewer { layout, mode, open_to } writes the catalog's opening preferences: /PageLayout from PageLayout (SinglePage, OneColumn, TwoColumnLeft, TwoColumnRight, TwoPageLeft, TwoPageRight), /PageMode from PageMode (UseNone, UseOutlines, UseThumbs, FullScreen), and /OpenAction opening the document at a page, keeping position and zoom.
use pdfboss_write::{
Attachment, Bookmark, LabelStyle, Outline, Page, PageLabel, PageMode, PageSize, Pdf,
Standard14, Viewer,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut cover = Page::new(PageSize::A4);
cover
.canvas
.text("Cover", 72.0, 770.0, Standard14::HelveticaBold, 24.0)?;
let mut body = Page::new(PageSize::A4);
body.canvas
.text("Body", 72.0, 770.0, Standard14::Helvetica, 12.0)?;
let pdf = Pdf {
pages: vec![cover, body],
outline: Some(Outline {
bookmarks: vec![Bookmark::new("Cover", 0), Bookmark::new("Body", 1)],
}),
attachments: vec![Attachment {
name: "raw-numbers.csv".into(),
data: b"a,b,c\n1,2,3\n".to_vec(),
mime: Some("text/csv".into()),
modified: None,
description: Some("Source data".into()),
}],
page_labels: vec![PageLabel {
first_page: 0,
style: Some(LabelStyle::RomanLower),
prefix: None,
start_at: 1,
}],
viewer: Some(Viewer {
mode: Some(PageMode::UseOutlines),
..Viewer::default()
}),
..Pdf::default()
};
pdf.save("book.pdf")?;
Ok(())
}
Python: pdfboss.write
pdfboss.write exposes the same vocabulary as frozen values joined with |. A Page(size="a4", landscape=False) composes Text, Image, Link and Paragraph elements; a Pdf() composes pages, Attachment and PageLabel values (each appended, in order) and the singleton Metadata, Outline and Viewer slots, where a second raises TypeError. Every | returns a new value and leaves the receiver unchanged, and copies are cheap handle clones: nothing is built until save(path) or to_bytes(), which lower the composition once and release the GIL to serialize. to_bytes may be called repeatedly.
from pdfboss.write import (
Bookmark,
Link,
Metadata,
Outline,
Page,
Paragraph,
Pdf,
Standard14,
Text,
)
cover = (
Page(size="a4")
| Text("Q3 Report", at=(72, 770), font=Standard14.HELVETICA_BOLD, size=28)
| Paragraph("Prepared for the board.", rect=(72, 700, 500, 740))
| Link(rect=(72, 60, 200, 80), url="https://example.com/q3")
)
pdf = Pdf() | Metadata(title="Q3 Report") | cover | Outline(Bookmark("Cover", 0))
pdf.save("q3.pdf")
Constructors mirror the Rust fields with Python spellings: Standard14 members are SCREAMING_SNAKE (Standard14.HELVETICA_BOLD), string enums are kebab-case (align="justify", style="roman-lower", layout="single-page", mode="use-outlines"), Text takes an optional (r, g, b) color tuple, Image takes a path string or raw bytes (read and decoded only at save/to_bytes time), Link takes exactly one of url or page, and Bookmark(title, page, children=(...)) nests by construction rather than |. The Python write surface stays clock-free, so Metadata and Attachment carry no date parameters. The full inventory is in the Python API reference.
The draw protocol
Any object with a callable draw attribute composes onto a Page like an element. During save/to_bytes the page's in-progress canvas is handed to draw(canvas) as a Canvas value with twelve methods: text, line, rect, move_to, line_to, curve_to, close, stroke, fill, set_fill, set_stroke and set_line_width. Painting lands in content order, exactly where the object sits in the | chain.
from pdfboss.write import Page, Pdf, Text
class Letterhead:
def draw(self, canvas):
canvas.line(72, 806, 523, 806, width=0.5)
canvas.text("ACME GmbH", at=(72, 812), size=8)
page = Page(size="a4") | Letterhead() | Text("Body copy", at=(72, 700))
data = (Pdf() | page).to_bytes()
The canvas is only usable inside the call: any method raises PdfError once draw has returned. An exception raised inside draw propagates from save/to_bytes exactly as the Python code raised it. The protocol is structural: the stub declares a Draw protocol type for checkers, but there is no runtime class to import or inherit.
Metadata and dates
Metadata fills the document information dictionary: title, author, subject, keywords, creator, producer, creation_date, modification_date. Every field is an Option, and an all-None value writes no /Info dictionary at all. Dates are explicit Date { year, month, day, hour, minute, second, utc_offset_minutes } values; the writer never reads a clock, so dates appear in output only when supplied.
Any Some metadata, all-None included, also writes an XMP metadata stream wired into the catalog as /Metadata, built from the same value so the two never drift: title becomes dc:title, author dc:creator, subject dc:description, keywords pdf:Keywords, producer pdf:Producer, creator xmp:CreatorTool, and the dates xmp:CreateDate/xmp:ModifyDate in ISO-8601. The packet carries no xmpMM:InstanceID, no xmpMM:DocumentID and no generated timestamps. Nothing in the crate reads clocks or randomness, and the file identifier derives from a hash of the emitted body, so the same input produces byte-identical output.
Writing the file
Four paths produce the same bytes:
pdf.save(path): serialize and write to a file.pdf.to_bytes(): the whole file as oneVec<u8>.pdf.write_into(impl std::io::Write): the same bytes streamed in bounded chunks. An error can leave a prefix of the file already written, and no flush is performed. Flush a buffered writer yourself.pdf.write_into_with(sink).await: the asynchronous twin over anyAsyncByteSink; it hands the sink back unflushed.Vec<u8>is a sink,Immediatepresents anystd::io::Writeas one, andpdfboss_aio::TokioSink(behind that crate'swritefeature) presents anytokio::io::AsyncWrite.
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Asynchronous emission", 72.0, 770.0, Standard14::Helvetica, 14.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
let bytes = pdf.write_into_with(Vec::new()).await?;
tokio::fs::write("async.pdf", &bytes).await?;
Ok(())
}
To stream into a tokio writer directly, wrap it in pdfboss_aio::TokioSink
(the write feature of pdfboss-aio). The one line is
pdf.write_into_with(TokioSink(writer)).await?; the writer comes back out of
the returned sink's .0 field, unflushed, so flush it yourself:
use pdfboss_aio::TokioSink;
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
use tokio::io::AsyncWriteExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Streamed through tokio", 72.0, 770.0, Standard14::Helvetica, 14.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
let file = tokio::fs::File::create("streamed.pdf").await?;
let mut sink = pdf.write_into_with(TokioSink(file)).await?;
sink.0.flush().await?;
Ok(())
}
Pdf::options is a WriteOptions controlling file emission. xref picks the cross-reference flavor: XrefStyle::Stream (the default) emits a compact PDF 1.5+ cross-reference stream, XrefStyle::Table a classic xref table with a trailer dictionary readable by PDF 1.0-era consumers. compress Flate-compresses stream data that carries no filter of its own (JPEG passthrough keeps its /DCTDecode and is never recompressed). object_streams packs non-stream objects into object streams, effective only with XrefStyle::Stream. version is the header version. The defaults are Stream, compressed, object streams on, version 1.7. For maximum compatibility:
use std::fs::File;
use std::io::{BufWriter, Write};
use pdfboss_write::{Page, PageSize, Pdf, Standard14, WriteOptions, XrefStyle};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::Letter);
page.canvas
.text("Classic xref table", 72.0, 700.0, Standard14::Courier, 12.0)?;
let pdf = Pdf {
pages: vec![page],
options: WriteOptions {
xref: XrefStyle::Table,
compress: false,
object_streams: false,
version: (1, 4),
},
..Pdf::default()
};
let mut out = BufWriter::new(File::create("classic.pdf")?);
pdf.write_into(&mut out)?;
out.flush()?;
Ok(())
}
CLI
pdfboss create covers the common cases without writing Rust, plus create md, which composes a Markdown file with a CSS theme and has its own chapter. Blank pages, with --pages, --size (a3, a4, a5, letter, legal) and --landscape:
pdfboss create blank --out blank.pdf --pages 3 --size a5 --landscape
A UTF-8 text file, word-wrapped into pages. --font takes any of the fourteen standard faces, plus --font-size and --margin in points:
pdfboss create text notes.txt --out notes.pdf --font times-roman --font-size 12
One page per input image (PNG or JPEG, detected by content); without --size, each page matches its image at 72 dpi:
pdfboss create images photo.png --out photos.pdf
A TOML manifest describes metadata and whole pages declaratively, mapping [meta], [[page]], [[page.text]], [[page.paragraph]], [[page.image]] and [[page.link]] tables onto the element vocabulary above; the schema is in the CLI reference:
pdfboss create manifest q3.toml -o q3.pdf
Each result can be checked immediately with pdfboss info, which reports the version, page count and page sizes. The full flag reference is in the CLI reference.
Round trip
The writer and the reader are two halves of the same engine: generated content streams parse with pdfboss_core::content like any other PDF, so a created document reads back without leaving the process.
use pdfboss_write::{Page, PageSize, Pdf, Standard14};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut page = Page::new(PageSize::A4);
page.canvas
.text("Round trip", 72.0, 770.0, Standard14::Helvetica, 18.0)?;
let pdf = Pdf {
pages: vec![page],
..Pdf::default()
};
let bytes = pdf.to_bytes()?;
let doc = pdfboss_core::Document::load(bytes)?;
let first = doc.page(0)?;
let text = pdfboss_output::extract_text(&doc, &first)?;
println!("{text}");
Ok(())
}
This prints Round trip. The same holds across tools: pdfboss info reads back the metadata, pdfboss text extracts the drawn strings, and pdfboss render rasterizes the page. One render detail: the standard fourteen faces carry no embedded font program, and rendering paints embedded programs by default. Pass --fonts full to substitute bundled faces and see the text in the raster.
Watermarking an existing file
watermark draws the first page of one document over every page of another. It does not rewrite the base file: the result is the base's bytes followed by an incremental update (ISO 32000-1 §7.5.6) holding the overlay page as a form XObject, its resources copied into the base's object space, and one replacement dictionary per page whose content is wrapped in q … Q before the form is drawn. The output therefore grows by the overlay page's size, keeps the base's cross-reference style, and takes no longer than parsing the two files. An encrypted base is refused.
use pdfboss_core::Document;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let base = Document::open("report.pdf")?;
let overlay = Document::open("draft-stamp.pdf")?;
let bytes = pdfboss_write::watermark(&base, &overlay)?;
std::fs::write("report-stamped.pdf", bytes)?;
Ok(())
}
watermark_with(base, overlay, WriteOptions::default()) is the other shape of the same operation: a fresh file written through the Writer, every object the catalog reaches copied over, filterless streams compressed and the rest packed into object streams, so unreachable objects and earlier update sections are left behind and the result is usually smaller than the base.
From Python, pdfboss.write.watermark(data, overlay) takes and returns bytes; rewrite=True selects the fresh-file shape:
import pdfboss
stamped = pdfboss.write.watermark(open("report.pdf", "rb").read(), open("draft-stamp.pdf", "rb").read())
open("report-stamped.pdf", "wb").write(stamped)
Markdown to PDF
pdfboss composes CommonMark+GFM source into a themed, paginated PDF: the
pdfboss create md CLI subcommand, pdfboss.md.to_pdf in Python, and
pdfboss_markdown::to_pdf in Rust. This is the reverse of
Markdown output, which extracts Markdown from a PDF; the
composed document reads back through the whole toolkit like any other file.
What the composition covers: headings, paragraphs, bulleted and numbered
lists (nested included), GFM tables with per-column alignment, fenced and
indented code blocks, block quotes, thematic breaks, emphasis, strong,
strikethrough and inline code runs, hyperlinks (emitted as real clickable
/Link annotations), and images (PNG or JPEG, detected by content, with
relative paths resolved against a base directory). Raw HTML fragments are
skipped and reported rather than half-rendered.
Two properties worth designing around:
- Deterministic. The same markdown, theme and options always produce byte-identical output: nothing reads a clock, randomness or the environment.
- Replace-and-report. Text renders in the standard Helvetica, Times and
Courier families, which encode as WinAnsi. A character outside that
encoding is replaced with
?and tallied in a report naming each replaced character; a clean document reports nothing.
Themes
A theme is a small CSS subset: element-type selectors only, cascading over
the built-in default theme, with inheritance from body down. The twenty
selectable elements are body, h1–h6, p, code, pre, blockquote,
ul, ol, li, table, th, td, a, del and hr. Properties
include font-family (helvetica, times, courier, or the sans-serif/
serif/monospace aliases), font-size (pt, px, em, mm, cm,
in), font-style, font-weight, color and background-color (named,
#hex or rgb()), text-align, text-decoration, line-height, and
margin/padding with their per-side forms. Parse errors are strict and
located: a typo fails with a line and column rather than being ignored.
body { font-family: times; font-size: 10.5pt; color: #222; }
h1 { font-family: helvetica; font-size: 2.2em; color: #a33; }
code { font-family: courier; background-color: #eee; }
pre { background-color: #eee; padding: 8pt; }
CLI
pdfboss create md notes.md -o notes.pdf --theme theme.css --size letter
--theme takes a CSS file (omitted, the built-in default theme applies);
--size is a3, a4 (default), a5, letter or legal, and
--landscape swaps width and height. Relative image paths in the markdown
resolve against the input file's directory. The result round-trips
immediately:
pdfboss info themed.pdf # 1 page, 612 x 792 pt
pdfboss text themed.pdf # the composed text back out
Python
pdfboss.md.to_pdf returns the PDF as bytes. theme is CSS source text,
not a path: read the file yourself when the theme lives in one. size
names the page size case-insensitively, landscape swaps the dimensions,
and base_dir anchors relative image paths (default: the current
directory).
from pathlib import Path
import pdfboss
theme = Path("theme.css").read_text()
pdf = pdfboss.md.to_pdf(Path("notes.md").read_text(), theme=theme, size="letter")
Path("notes.pdf").write_bytes(pdf)
Replacements and skipped raw HTML surface as a single UserWarning naming
what changed, so a clean run stays silent and a lossy one is visible without
being fatal:
import warnings
import pdfboss
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
pdf = pdfboss.md.to_pdf("Snowman ☃ here")
for warning in caught:
print(warning.message) # replaced 1 character unavailable in the standard fonts: '☃'×1
An unknown size or an invalid theme raises PdfError.
Rust
pdfboss_markdown::to_pdf(markdown, &options) returns the composed
pdfboss_write::Pdf (still a value, not yet bytes)
alongside the replace-and-report Report. Options carries the parsed
Theme, the PageSize and the image base_dir; Theme::parse reads CSS
source, Theme::default_theme() is the built-in look. Serialize the Pdf
with any of the write paths:
use pdfboss_markdown::{to_pdf, Options, PageSize, Theme};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let markdown = std::fs::read_to_string("notes.md")?;
let theme = Theme::parse("h1 { font-family: helvetica; color: #a33; }")?;
let options = Options {
theme,
page_size: PageSize::Letter,
..Options::default()
};
let (pdf, report) = to_pdf(&markdown, &options)?;
if !report.is_empty() {
eprintln!("{}", report.summary());
}
pdf.save("notes.pdf")?;
Ok(())
}
Because the result is a plain Pdf, everything from
Creating PDFs applies afterwards: set metadata, attach an
outline or embedded files, add page labels or viewer preferences, append
pages of your own, or stream the bytes asynchronously.
Round trip
Composition and extraction are the two directions of one engine. A document
composed from Markdown reads back with pdfboss text, renders with
pdfboss render, and, closing the loop, pdfboss md re-infers headings,
lists and tables from the composed layout. The composed text uses only
standard-14 faces, so rendering paints it at the full
font tier.
Async and remote PDFs over HTTP
AsyncDocument opens a PDF without reading the whole file. The open flow fetches only what it needs (the header, the cross-reference chain and the page tree) and every later operation fetches only the byte ranges it touches. The file backend reads windows of the file on demand; the HTTP backend turns each read into a Range request, so a document on a server can be paged through without downloading it. A server that ignores Range and answers 200 with the full body (python3 -m http.server, for one) still works: the first such answer is kept as the whole resource and every read is served from it, at the cost of one full download held in memory for the life of the document.
Python
Three constructors, all coroutines. Each takes password= for encrypted files (see Encrypted documents):
| Constructor | Source |
|---|---|
AsyncDocument.open(path) | A local file, read in ranges |
AsyncDocument.open_url(url) | An http(s) URL, fetched via Range requests |
AsyncDocument.from_bytes(data) | Bytes already in memory |
What is sync and what is a coroutine follows from what the open flow already parsed. page_count, version, len(doc) and page access (doc[i], doc.page(i) and every AsyncPage geometry property) are plain sync attributes: the xref chain and the page tree were parsed at open, so nothing there needs I/O. Everything that must read more of the file is a coroutine: extract_text, extract_markdown, render_pages, metadata, get_object on the document, and extract_text, extract_markdown, render, render_reporting, extract_images and spans on a page.
Coroutines are driven by one shared multi-thread tokio runtime behind the asyncio loop. render_pages fans pages across the machine's cores as tokio tasks, so the loop stays free while pages rasterize.
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open("report.pdf")
print(doc.page_count, doc.version)
text = await doc.extract_text()
metadata = await doc.metadata()
png = await doc[0].render(scale=2.0)
async for span in doc.spans(pages=[0]):
print(span.text, span.font_name)
asyncio.run(main())
doc.elements() and doc.spans() return async iterators, consumed with async for, with the same ordering and salvage semantics as their sync twins. See Exploring PDF internals and Styled spans.
A remote document differs only in the constructor:
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open_url("https://example.com/report.pdf")
print(doc.page_count)
print(await doc.extract_markdown())
asyncio.run(main())
Rust
pdfboss_aio::AsyncDocument has the same constructors: open/open_with_password, from_bytes/from_bytes_with_password and, behind the crate's http feature, open_url/open_url_with_password. with_backend opens a document over any byte source you build yourself.
use pdfboss_aio::AsyncDocument;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open_url("https://example.com/report.pdf").await?;
println!("{} pages", doc.page_count());
let metadata = doc.metadata().await?;
println!("{:?}", metadata.title);
Ok(())
}
Backends
A document reads through the Backend trait: len() and read_at(offset, buf), both returning boxed futures so the trait is object-safe and a document can hold Arc<dyn Backend>. Four implementations ship with the crate:
MemBackend: bytes fully resident in memory;from_bytesuses it directly, with no cache.FileBackend: positioned reads (pread-style, no shared cursor) run on tokio's blocking thread pool, so disk I/O never stalls the async runtime. The length is captured once at open; the file is treated as immutable while the backend lives.HttpBackend(featurehttp): the length comes from aHEADrequest'sContent-Length; each read is aGETwith aRange: bytes=header. A200answer where206was asked for means the server ignoresRange; its body is the whole resource, so it is collected once (capped at the declared length, so a buggy or hostile server cannot balloon memory) and all reads are served from it.on_fallback_progressregisters an observer for that one-time download (the CLI draws its stderr progress bar through it). A206body is likewise collected only up to the requested size.CachedBackend: a chunked LRU read cache over any backend: many small reads become few chunk-sized fetches, and hot chunks stay resident up to a byte budget. Defaults: 64 KiB chunks, 32 MiB total. Misses batch adaptively: a miss landing near the previous one doubles the batch, up to 8 MiB (and a quarter of the budget), a far jump halves it, and each miss fetches its uncached neighborhood, growing around the missed chunk until it hits resident chunks or the batch budget. Dense access over a high-latency server collapses into few large requests, whichever direction the reader walks the file, while scattered access never over-fetches.on_fetchregisters an observer called with the offset and length of every inner fetch a miss triggers (the CLI draws its ranged-open coverage minimap through it); cache hits never call it.
open and open_url wrap their backend in a CachedBackend automatically. from_bytes stays uncached, and with_backend adds nothing, so a composition of your own is used exactly as given (with_backend_with_password is the same for encrypted files):
use pdfboss_aio::{AsyncDocument, CachedBackend, FileBackend};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let backend = FileBackend::open("report.pdf").await?;
let cached = CachedBackend::with_capacity(backend, 128 * 1024, 64 * 1024 * 1024);
let doc = AsyncDocument::with_backend(cached).await?;
println!("{} pages", doc.page_count());
Ok(())
}
The sync crates over an async document
The extraction and rendering crates are written sans-I/O. Each entry point is implemented as a *_with function generic over pdfboss_core::AsyncObjectSource; the sync signature is that same implementation run over an immediate, no-I/O source. AsyncDocument implements AsyncObjectSource, so pdfboss_output::extract_text_with, pdfboss_output::extract_page_markdown_with, pdfboss_render::render_page_reporting_with and pdfboss_render::extract_page_images_with all run over range-fetching reads unchanged. The document is an Arc handle: cloning one to hand to an entry point by value costs two atomic increments.
use pdfboss_aio::AsyncDocument;
use pdfboss_output::extract_text_with;
use pdfboss_render::extract_page_images_with;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open("report.pdf").await?;
println!("{} pages", doc.page_count());
let page = doc.page(0)?;
let oc = doc.oc_state().await;
let text = extract_text_with(doc.clone(), &page, oc.as_ref()).await?;
println!("{text}");
let images = extract_page_images_with(doc.clone(), &page).await?;
for (index, image) in images.iter().enumerate() {
image.save_png(format!("image-{index}.png"))?;
}
Ok(())
}
The oc parameter carries the document's optional-content visibility (doc.oc_state().await); text and markdown extraction use it to exclude layers the document's default configuration turns off, exactly as the sync entry points do. Rendering takes the same state through RenderOptions::oc: set opts.oc = doc.oc_state().await.map(Arc::new); before calling render_page_reporting_with. Leaving it None renders every layer; only the sync entry points fill it from the document. What the extracted images contain (drawing order, native size, /SMask alpha) is described in Extracting images; the sync Rust and Python surfaces are in Rust crates and Python API.
Exploring PDF internals
A PDF is two structures at once: a physical file (header, numbered objects, cross-reference sections, trailer) and the logical document those objects encode (pages, fonts, images, annotations). pdfboss exposes both as one lazy stream of elements, reachable from Python and Rust, and from the CLI as a JSON tree, jq queries, hexdumps and an interactive terminal explorer.
The element model
A walk yields the physical elements first, in file order, each with its byte span in the file:
| kind | What it is |
|---|---|
header | The %PDF-1.x marker |
object | One indirect object (N G obj … endobj); ref carries (num, gen) |
xref | One cross-reference section, table or stream |
trailer | The trailer dictionary |
startxref | The startxref pointer |
eof | The %%EOF marker |
Then the logical elements, in document order:
| kind | What it is |
|---|---|
page | One page; page carries the 0-based index |
font, image, annotation | A page's resources, under that page's index |
content_op | One content-stream operator; the span is the range within the page's decoded content stream |
Parsing is lazy: nothing is located, parsed or decoded before it is yielded. Iteration salvages: an element that cannot be parsed raises for that item alone, and the walk continues past it.
Python
Document.elements() returns a lazy iterator; each step releases the GIL while the next element is parsed. Keyword arguments select the layers: physical= and logical= toggle the two passes, pages= restricts logical elements to the 0-based pages given, and content_ops=True adds the (high-volume) per-page operators.
import pdfboss
doc = pdfboss.Document("report.pdf")
for element in doc.elements(logical=False):
print(element.kind, element.span, element.ref)
Each Element carries kind, span (byte range, where applicable), ref (the (num, gen) object reference, where applicable) and page (0-based index for logical elements). value() converts the element lazily to plain Python data: dicts, lists, str, bytes, numbers, bool, None; PDF names become str, streams become {"dict": ..., "length": n}, references become {"ref": (num, gen)}. That full conversion applies to object and trailer elements; the other kinds convert to fixed shapes:
| kind | value() |
|---|---|
header | the version string, e.g. "1.7" |
xref | {"kind": "table" or "stream", "entries": int} |
startxref | the offset as int |
font | {"subtype": str, "base_font": str or None} |
image | {"width": int, "height": int} |
annotation | {"subtype": str} |
content_op | the operator rendered as a string |
eof, page | None |
import pdfboss
doc = pdfboss.Document("report.pdf")
for element in doc.elements(physical=False, pages=[0]):
if element.kind != "font":
continue
print(element.value())
A for loop stops at the first raising element, so a walk that must survive damage drives the iterator explicitly. A per-item PdfError leaves the iterator usable:
import pdfboss
doc = pdfboss.Document("report.pdf")
elements = doc.elements()
while True:
try:
element = next(elements)
except StopIteration:
break
except pdfboss.PdfError as err:
print("unreadable element:", err)
continue
print(element.kind)
AsyncDocument.elements() is the async twin: same arguments, same ordering, same salvage semantics, consumed with async for (Async and remote documents).
Rust
The same walk in Rust is pdfboss_core::Document::elements(ElementOpts), an iterator of Result<Element>. ElementOpts selects the layers with the same four knobs (physical, logical, pages, content_ops); Element is an enum; variants carry their payload fields directly, with byte spans on the physical variants.
use pdfboss_core::{Document, Element, ElementOpts};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = Document::open("report.pdf")?;
for element in doc.elements(ElementOpts::default()) {
match element {
Ok(Element::IndirectObject { r, span, .. }) => {
println!("{} {} obj at {}..{}", r.num, r.gen, span.start, span.end);
}
Ok(_) => {}
Err(err) => eprintln!("unreadable element: {err}"),
}
}
Ok(())
}
pdfboss_aio::AsyncDocument::elements(ElementOpts) returns an ElementStream, a futures_core::Stream of Result<Element> with the same ordering and salvage semantics. The stream owns an Arc clone of the document, so it is 'static and can be spawned:
use futures_util::StreamExt;
use pdfboss_aio::AsyncDocument;
use pdfboss_core::ElementOpts;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open("report.pdf").await?;
let mut elements = doc.elements(ElementOpts::default());
while let Some(element) = elements.next().await {
match element {
Ok(element) => println!("{element:?}"),
Err(err) => eprintln!("unreadable element: {err}"),
}
}
Ok(())
}
CLI
Four subcommands plus the terminal explorer. json, q, hex and tui accept a local path or an http(s) URL (a URL is fetched in ranges when the server honors Range; one that doesn't costs a single full download); obj takes a local path. Full flag listings are in the CLI reference.
json: the document as a JSON value tree
pdfboss json report.pdf > report.json
The tree's top-level keys are header, objects (keyed "N G"), pages, xref, trailer and startxref. Physical entries carry a _span byte range; indirect references appear as {"_r": [num, gen]}. --layout adds a top-level layout array: per page, the inferred blocks (headings, paragraphs, lists, tables). --pages restricts the logical layer, --no-logical skips it, --content-ops adds per-page operators, and --raw/--decode embed stream data as base64 (still encoded, or decoded).
q: jq programs over the same tree
pdfboss q report.pdf '. | keys'
pdfboss q report.pdf '[.pages[].fonts[].base_font] | unique'
pdfboss q report.pdf '.objects["2 0"]'
The second one answers "which fonts does this document use" in one line:
[
"BAMEDE+StoneSans-Bold",
"BAMFAO+StoneSerif-Italic",
...
"Helvetica",
"Helvetica-Bold"
]
-r prints string results raw, and --hex hexdumps any result that carries a _span instead of printing its JSON: a query language for choosing what to dump.
hex: the bytes themselves
pdfboss hex report.pdf obj:2 # one object's bytes
pdfboss hex report.pdf trailer # or: header, xref:0, range:0x100-0x140
pdfboss hex report.pdf --annotate # whole file, element boundaries labeled
000000aa 32 20 30 20 6f 62 6a 0d 3c 3c 20 0d 2f 50 72 6f |2 0 obj.<< ./Pro|
000000ba 63 53 65 74 20 5b 20 2f 50 44 46 20 2f 54 65 78 |cSet [ /PDF /Tex|
Selectors: obj:N[,G], header, xref:N (sections indexed in chain order, newest first), trailer, range:START-END (offsets decimal or 0x-hex); without one, the whole file. --annotate prints a labeled boundary line as the dump crosses each element.
obj: one object, pretty-printed
pdfboss obj report.pdf 2
<<
/ColorSpace <<
/Cs5 122 0 R
...
>>
/ExtGState <<
/GS1 148 0 R
>>
/Font <<
/F1 132 0 R
...
>>
/ProcSet [/PDF /Text /ImageB /ImageC]
...
>>
tui: the interactive explorer
pdfboss tui report.pdf
The screen splits into a tree pane on the left, an inspector above a hex pane on the right, and a status bar. The tree is the element model as a lazy hierarchy, populated by background tasks as sections expand: Document → Pages (each with its Fonts, Images, Annotations and Contents) → Objects → Xref sections → Trailer. The inspector pretty-prints the selection; d cycles it through raw bytes, decoded bytes and disassembled content operators for streams, and Enter jumps through any N G R reference under the cursor (Backspace goes back). p swaps the inspector for a rasterized page preview and m for the page's Markdown; both follow the selection when it moves to another page. The hex pane tracks the selection's bytes.
Tab cycles focus, arrows or j/k/h/l move, g/G jump to top/bottom, / searches (with n/N for next/previous hit), q quits. Alt+arrows (Option on macOS) resize the panes: left/right move the tree divider, up/down the inspector/hex divider (Ctrl+arrows and Ctrl+Shift+arrows also work where the terminal delivers them, and the ESC b/ESC f word motions stock iTerm2 and Terminal.app send for Option+Left/Right are accepted as horizontal resizes). Long operations (element streaming, hex fetches, search, preview rasterization) run off the event loop, so input never blocks, including over an HTTP-backed document.
y opens a yank menu that copies the selection to the clipboard: q the pdfboss q expression addressing it (.objects["12 0"], .pages[0].fonts, .trailer), c the full shell command, x a hexdump of its bytes, b the raw bytes, e the pretty-printed element, m the page's Markdown, o the object reference (12 0 R), Esc cancels. Copies go to the native clipboard, falling back to the OSC 52 escape sequence (which works over SSH in terminals that support it). A selection past 1 MiB yields the equivalent pdfboss hex command instead of the bytes.
Encrypted PDFs
pdfboss opens files encrypted with the PDF Standard security handler: RC4
(40–128-bit, /V 1–2, and /V 4 with crypt filter V2), AES-128 (/V 4,
crypt filter AESV2) and AES-256 (/V 5, crypt filter AESV3). Either the user password or the owner
password opens the document, and both unlock the same full content. A file
protected only by an owner password has an empty user password and opens
transparently, with no password at all.
Non-ASCII passwords are tried UTF-8 encoded and, for the legacy RC4/AES-128 revisions, Latin-1 encoded as well, covering both encodings real files use.
Checking whether a file needs a password
pdfboss info never fails on an encrypted file. Whether the file needs a
password is the very question being asked:
pdfboss info locked.pdf
version: 1.7
encrypted: true
pages: unknown
encrypted: true means the file did not open with the password supplied (by
default, none). A file that opens (because it is unencrypted, protected only
by an owner password, or because --password carried the right value)
reports encrypted: false along with the full page and metadata listing:
pdfboss info --password hunter2 locked.pdf
version: 1.7
encrypted: false
pages: 1
page 1: 612 x 792 pt
CLI
Every subcommand that reads a PDF (info, text, md,
render, images, obj, tui, json, hex and q) takes --password,
accepted as either the user or the owner password:
pdfboss text --password hunter2 locked.pdf
Apart from info, a command given no password (or a wrong one) for a
password-protected file prints an error and exits nonzero.
Python
Document takes a password keyword, and raises PdfError when the file
needs a password it was not given (or the given one is wrong):
import pdfboss
try:
doc = pdfboss.Document("locked.pdf")
except pdfboss.PdfError:
doc = pdfboss.Document("locked.pdf", password="hunter2")
print(doc.extract_text())
The same keyword exists on the data= form of the constructor and on all
three async constructors: AsyncDocument.open, AsyncDocument.open_url and
AsyncDocument.from_bytes (see
Async and remote documents):
import asyncio
import pdfboss
async def main() -> None:
doc = await pdfboss.AsyncDocument.open("locked.pdf", password="hunter2")
print(doc.page_count)
asyncio.run(main())
Rust
Document::open (and Document::load for bytes in memory) handles the
empty-user-password case on its own and returns Error::Encrypted when a
real password is needed; open_with_password/load_with_password take one:
use pdfboss_core::{Document, Error};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = match Document::open("report.pdf") {
Err(Error::Encrypted) => Document::open_with_password("report.pdf", "hunter2")?,
other => other?,
};
println!("{} pages", doc.page_count());
Ok(())
}
A wrong password also comes back as Error::Encrypted. The async document
mirrors the sync surface with AsyncDocument::open_with_password,
open_url_with_password and from_bytes_with_password:
use pdfboss_aio::AsyncDocument;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let doc = AsyncDocument::open_with_password("report.pdf", "hunter2").await?;
println!("{} pages", doc.page_count());
Ok(())
}
Once a document is open, every operation (text, markdown, rendering, images) works exactly as on an unencrypted file; decryption happens transparently underneath. Full option listings live in the CLI reference.
pdfboss CLI reference
One binary, pdfboss, with a subcommand per job. This chapter is the flag inventory; worked examples live in the guide chapters linked from each entry.
Shared behavior:
- Every subcommand that reads a PDF takes
--password <PASSWORD>for encrypted files: user or owner password; the empty user password opens transparently. See Encrypted documents. - The explorer subcommands (
tui,json,hex,q) accept either a local path or anhttp(s)://URL as input. URLs are range-fetched, and when stderr is a terminal the open draws a coverage minimap there: a caret marking the byte region being fetched over a map of which stretches of the file have arrived, erased once the document is open. A server that ignoresRangecosts one full download instead, reported with a progress bar. The other subcommands take a local path. - Exit codes: 0 on success, 1 for PDF and I/O problems, 2 for an invalid jq program (mirroring clap's own usage-error code).
renderandimagesare lenient: content that cannot be read is skipped with a warning on stderr, and the exit code stays 0.
info
Show version, page count, page sizes and metadata.
pdfboss info [OPTIONS] <FILE>
pdfboss info report.pdf
text
Extract text; all pages separated by form feed unless --page (1-based) is given. See Extracting text.
pdfboss text [OPTIONS] <FILE>
pdfboss text report.pdf --page 1
md
Extract markdown, with headings, lists and tables inferred from layout. --page <PAGE> restricts to one 1-based page: heading sizes are then judged per page, not across the document. See Markdown output.
pdfboss md [OPTIONS] <FILE>
pdfboss md report.pdf > report.md
render
Render a page to PNG, PPM, BMP or JPEG. See Rendering pages.
pdfboss render [OPTIONS] --page <PAGE> <FILE>
--page <PAGE>: 1-based page number (required)-o, --out <OUT>: output file; its extension picks the format,.png,.ppm,.bmpor.jpg(default:page-N.png)--scale <SCALE>: scale factor (default: 1)--fonts <FONTS>: which fonts to paint, one ofembedded-only(only embedded TrueType outlines, fastest),all-embedded(every embedded program),full(also substitute bundled faces for non-embedded fonts); the default resolves tofullwhen substitute faces are available (the compiled-in OFL set or--font-dir), otherwiseall-embedded--font-dir <FONT_DIR>: directory of substitute faces for--fonts full; overrides the compiled-in OFL set--png-compression <PNG_COMPRESSION>:none,fast,defaultorbest(encode time against file size, same pixels; PNG only)--jpeg-quality <JPEG_QUALITY>: 1 to 100 (default 90; JPEG only)
pdfboss render --page 1 --scale 2 -o page-1.png report.pdf
pdfboss render --page 1 --scale 2 -o page-1.ppm report.pdf
pdfboss render --page 1 --scale 2 -o page-1.jpg --jpeg-quality 80 report.pdf
images
Extract every image a page draws, each as a native-size PNG. See Extracting images.
pdfboss images [OPTIONS] <FILE>
--page <PAGE>: 1-based page number (default: all pages)-o, --out <OUT>: output directory, which must already exist (default: current directory)--png-compression <PNG_COMPRESSION>: as inrender
pdfboss images report.pdf --page 1 -o out
obj
Pretty-print a single object by number, with an optional generation number (default 0).
pdfboss obj [OPTIONS] <FILE> <NUM> [GEN]
pdfboss obj report.pdf 1
tui
Explore a PDF interactively in the terminal: element tree, object inspector, hex view, page preview and Markdown preview. Takes a path or http(s) URL and requires an interactive terminal. See Exploring PDF internals.
pdfboss tui [OPTIONS] <TARGET>
pdfboss tui report.pdf
json
Dump the document as a JSON value tree, for piping to external tools.
pdfboss json [OPTIONS] <INPUT>
--raw: embed raw (still encoded) stream data as base64; combining it with--decodeis a usage error--decode: embed decoded stream data as base64--pages <PAGES>: restrict logical elements to these 1-based pages (comma separated)--no-logical: skip the logical layer (pages/fonts/images/annotations)--content-ops: include per-page content-stream operators (high volume)--layout: include per-page layout blocks (headings, paragraphs, lists, tables)
pdfboss json report.pdf --no-logical > tree.json
hex
Hexdump the file or a selected element, hexyl-style.
pdfboss hex [OPTIONS] <INPUT> [SELECTOR]
The selector is one of obj:N[,G], header, xref:N, trailer or range:START-END (offsets decimal or 0x-hex; xref sections indexed in chain order, newest first); without one, the whole file is dumped.
--annotate: print labeled element boundaries as the dump crosses them--width <WIDTH>: bytes per row (default: 16)
The dump is colorized with ANSI escapes when stdout is a tty; setting the
NO_COLOR environment variable (any value) disables color.
pdfboss hex report.pdf header
q
Run a jq program over the document's JSON value tree (the same tree json prints).
pdfboss q [OPTIONS] <INPUT> <PROGRAM>
--raw,--decode,--pages <PAGES>,--no-logical,--content-ops: as injson, including the--raw/--decodeusage error when combined--hex: hexdump results carrying a_spaninstead of printing JSON; colorized on a tty likehex, withNO_COLORhonored-r: print string results raw, without quotes (likejq -r)
pdfboss q report.pdf -r '.pages[].fonts[].base_font'
create
Create a new PDF: blank pages, word-wrapped text, image pages, a themed Markdown document, or a TOML manifest of composed pages.
pdfboss create <COMMAND>
Five subcommands, each writing to -o, --out <OUT>. The first four share --size a3|a4|a5|letter|legal and --landscape (swap page width and height); manifest takes neither, since page size and orientation live per page inside the TOML. See Creating PDFs and Markdown to PDF.
create blank
Empty pages. --pages <PAGES> sets the page count (default: 1); --size defaults to a4.
pdfboss create blank [OPTIONS] --out <OUT>
pdfboss create blank -o blank.pdf --pages 3 --size letter
create text
A UTF-8 text file, word-wrapped into pages.
pdfboss create text [OPTIONS] --out <OUT> <INPUT>
--font <FONT>: one of the fourteen standard fonts,helvetica(default),helvetica-bold,helvetica-oblique,helvetica-bold-oblique,times-roman,times-bold,times-italic,times-bold-italic,courier,courier-bold,courier-oblique,courier-bold-oblique,symbol,zapf-dingbats--font-size <FONT_SIZE>: font size in points (default: 11)--margin <MARGIN>: page margin in points, all four sides (default: 72)
pdfboss create text notes.txt -o notes.pdf --font times-roman --font-size 12
create images
One page per input image (PNG or JPEG, detected by content). Without --size, each page matches its image at 72 dpi. --landscape requires --size: passing it alone is a usage error, not a no-op.
pdfboss create images [OPTIONS] --out <OUT> <INPUTS>...
pdfboss create images scan-1.png scan-2.png -o scans.pdf
create md
A markdown file composed into a themed document. See Markdown to PDF.
pdfboss create md [OPTIONS] --out <OUT> <INPUT>
--theme <THEME>: CSS theme file (default: the built-in theme)--size <SIZE>: as above;--landscapeswaps width and height
Relative image paths in the markdown resolve against the input file's directory.
pdfboss create md notes.md -o notes.pdf --theme theme.css --size letter
create manifest
A TOML manifest describing metadata and pages: text, paragraphs, images and links mapped onto the compose vocabulary of pdfboss-write. See Creating PDFs for that vocabulary.
pdfboss create manifest --out <OUT> <INPUT>
The manifest's tables:
[meta]: optional document information, mapped onto/Info:title,author,subject,keywords,creator,producer, each a string.[[page]]: one table per page, in reading order.sizenames a page size case-insensitively (a3,a4,a5,letter,legal; absent defaults toa4) andlandscape(boolean) swaps width and height, both per page.[[page.text]]: one line of text:value,at = [x, y](the baseline origin), optionalfontandsize.[[page.paragraph]]: wrapped text:value,rect = [x0, y0, x1, y1], optionalfont,size,leadingandalign(left,center,right,justify).[[page.image]]: a placed raster:path(resolved relative to the manifest's directory, decoded by content as PNG or JPEG),at = [x, y], optionalwidthandheight.[[page.link]]: a clickable rectangle:rectplus exactly one ofurlorpage(a 0-based page index in the same document).
Font names are PostScript base names (Helvetica, Helvetica-Bold, Times-Roman, Courier-Oblique, …), unlike the kebab-case values of create text --font; an unknown name errors listing the valid set, and an absent one defaults to Helvetica. Unknown TOML keys are rejected, and every error message is prefixed with the manifest's path. Within a page, content lowers in schema order (text, then paragraphs, then images, then links) regardless of how the tables interleave in the file; TOML's separate arrays of tables carry no cross-type order.
[meta]
title = "Q3 Report"
author = "pdfboss"
[[page]]
size = "a4"
[[page.text]]
value = "Q3 Report"
at = [72, 770]
font = "Helvetica-Bold"
size = 28
[[page.paragraph]]
value = "Body copy for the quarter."
rect = [72, 380, 523, 720]
size = 11
leading = 15
align = "left"
[[page.image]]
path = "chart.png"
at = [72, 96]
width = 200
[[page.link]]
rect = [72, 88, 523, 380]
url = "https://example.com/q3"
pdfboss create manifest q3.toml -o q3.pdf
Python API reference
The pdfboss package re-exports the compiled extension module pdfboss._pdfboss. Its public surface is twelve top-level classes, the md and write submodules and the __version__ string; the typed stubs in _pdfboss.pyi are the authoritative reference for every signature and docstring. This chapter is the inventory; worked examples live in the guide chapters.
The twelve classes
| Name | What it is |
|---|---|
Document | A loaded PDF, from a path or bytes; pages by index, the metadata property, extract_text, extract_markdown, render_pages, elements, spans |
Page | One page: geometry (width/height/rotation and the five boxes), extract_text, extract_markdown, spans, render, render_reporting, extract_images |
AsyncDocument | The async twin of Document, opened from a path, bytes, or an HTTP URL via range requests; data-fetching methods are coroutines |
AsyncPage | The async twin of Page; attributes are synchronous, extraction and rendering are coroutines |
Element | One physical or logical element of a PDF (kind, span, ref, page, lazy value()), yielded by elements |
ElementIter | Lazy sync iterator over elements; each step releases the GIL |
AsyncElementIter | Async iterator over elements; each step is a coroutine |
Span | One styled text span: text, position, bbox, font identity, bold/italic/monospace/serif, underline/strikethrough, rise, vertical, invisible, color |
SpanIter | Lazy sync iterator over a document's spans, buffering one page at a time |
AsyncSpanIter | Async iterator over a document's spans |
PageImage | One embedded image extracted from a page: native width/height and PNG-encoded data |
PdfError | The exception type for any PDF processing error |
Document.metadata is a property returning the document information
dictionary as a dict[str, str], only keys present in the file included;
AsyncDocument.metadata() is a coroutine yielding the same mapping.
AsyncDocument also has page(index) (synchronous, 0-based, no negative
indexes; subscription doc[i] accepts them) and get_object(num, gen=0), a
coroutine fetching one indirect object and returning it through the same
plain-Python conversion as Element.value().
Guide chapters with runnable examples: Extracting text, Markdown output, Styled spans, Rendering pages, Extracting images, Creating PDFs, Markdown to PDF, Async and remote documents, Encrypted documents.
The md submodule
pdfboss.md.to_pdf(markdown, theme=None, size="a4", landscape=False, base_dir=None) composes CommonMark+GFM source into a themed PDF and returns the file bytes. theme is CSS source text, not a path; an unknown size raises PdfError; replaced characters and skipped raw HTML surface as one UserWarning. Details and examples in Markdown to PDF.
Canvas-level and element-level creation from Python is the write submodule below.
The write submodule
pdfboss.write composes new PDFs from frozen values joined with |; the same vocabulary is the Rust crate pdfboss-write and the pdfboss create CLI, and worked examples live in Creating PDFs. Fourteen classes:
| Name | What it is |
|---|---|
Pdf | A document under construction; composes pages, attachments and page labels (appended) and the singleton Metadata, Outline and Viewer slots; save(path) and to_bytes() serialize |
Page | One page (size names a size case-insensitively, default "a4"; landscape swaps width and height); composes Text, Image, Link, Paragraph or any draw object |
Text | One line of text: value, at=(x, y) baseline origin, font (default Standard14.HELVETICA), size (default 12.0), optional (r, g, b) color |
Paragraph | Wrapped, aligned text: text, rect, font, size (default 11.0), leading (default derived from size), align of left, center, right or justify |
Image | A placed raster: data as a path string or bytes, at, optional width/height; either source is read and decoded at save/to_bytes time |
Link | A clickable rectangle: rect plus exactly one of url or page (0-based) |
Bookmark | One outline entry: title, page, keyword-only children; nests by construction |
Outline | The bookmark panel, Outline(*bookmarks); a singleton Pdf slot |
Attachment | An embedded file: name, data, optional mime and description; carries no dates |
PageLabel | One page-numbering range: first_page (0-based), optional style (decimal, roman-upper, roman-lower, letters-upper, letters-lower), prefix, start_at (default 1) |
Viewer | Opening preferences: layout, mode, open_to; a singleton Pdf slot |
Metadata | /Info text fields: title, author, subject, keywords, creator, producer; a singleton Pdf slot |
Standard14 | The fourteen standard faces as SCREAMING_SNAKE members: HELVETICA, TIMES_BOLD_ITALIC, ZAPF_DINGBATS, … |
Canvas | The painting surface handed to a draw object's draw method; it has no public constructor |
Every | returns a new value and leaves the receiver unchanged; copies are cheap handle clones, and nothing is built until save or to_bytes, which lower the composition once under the GIL and release it to serialize. to_bytes may be called repeatedly, since the composed value is never consumed.
The draw protocol is structural: any object with a callable draw attribute composes onto a Page, and its draw(canvas) receives a Canvas with twelve methods (text, line, rect, move_to, line_to, curve_to, close, stroke, fill, set_fill, set_stroke, set_line_width) that paints in content order. The stub declares a Draw protocol type for checkers only; there is no runtime Draw class to import or inherit. The canvas is only usable inside the call, and every method raises PdfError once draw has returned.
One function works on existing files: watermark(data, overlay, *, rewrite=False) takes two PDFs as bytes and returns data with the first page of overlay drawn over every page, as an incremental update appended to data's bytes, or with rewrite=True as a fresh, compressed file (see Watermarking an existing file). It releases the GIL while it runs and raises PdfError for an encrypted data.
Error handling
Everything raises PdfError: bad or truncated data, unsupported encryption, stream decode failures and I/O errors, with the underlying detail in the message. Messages from the element and async APIs are prefixed by the layer they came from: "parse: …", "io: …" or "http: …".
import pdfboss
try:
doc = pdfboss.Document("report.pdf")
except pdfboss.PdfError as e:
print(f"could not open: {e}")
Two conventional exceptions apply where Python conventions demand them: constructing a Document with neither or both of path and data raises ValueError (as does a non-positive scale, an unknown fonts=, compression= or format= string, a quality= outside 1 to 100, or an unusable fonts="full" setup in render), and an out-of-range page index raises IndexError. Element iterators have salvage semantics: a per-item failure raises PdfError for that item, and iteration may be continued. Span iterators raise PdfError when a page cannot be materialized.
The write submodule splits its failures by phase. TypeError is raised at construction for a Link with neither or both of url and page, an unknown align, page-label style, viewer layout or mode, or Image data that is neither str nor bytes; and at composition for an unsupported | operand or a second Metadata, Outline or Viewer. Everything that fails while lowering (an unreadable or undecodable image file, a paragraph overflowing its rect, an unencodable character, a target page out of range) raises PdfError from save/to_bytes. A draw object's Canvas stops working the moment its draw call returns: any later method call on it raises PdfError at that call site. An exception raised inside draw() itself propagates from save/to_bytes exactly as the Python code raised it.
Threading
A Document (and any Page it hands out) may be used from any thread. Access to the underlying parsed document is serialized internally, and extract_text/render release the GIL while they run, so other Python threads keep making progress during long extractions or renders. Element and span iteration release the GIL per step the same way. Document.render_pages fans page rendering out across the machine's cores.
The async API needs no thread juggling: AsyncDocument's coroutines are driven by one global multi-thread tokio runtime, and AsyncDocument.render_pages fans out as tokio tasks so the asyncio loop stays free.
Rust crate reference
pdfboss is a workspace of focused crates, all sharing one version. Add the ones you need with cargo add; each crate's API reference lives on docs.rs.
| Crate | Responsibility | Docs |
|---|---|---|
pdfboss-core | PDF syntax, objects, filters, cross-references and document model (ISO 32000) | docs.rs |
pdfboss-text | Font loading, encodings, ToUnicode CMaps and text extraction | docs.rs |
pdfboss-output | Layout analysis and output rendering: plain text and markdown | docs.rs |
pdfboss-encoding | Shared font encoding tables and glyph-name mappings (ISO 32000 Appendix D) | docs.rs |
pdfboss-jpx | Cleanroom JPEG 2000 (JPXDecode) decoder (ITU-T T.800) | docs.rs |
pdfboss-icc | Cleanroom ICC profile parser and colour transform (ICC.1:2010) | docs.rs |
pdfboss-render | Page rasterization to RGBA pixmaps and PNG, plus embedded-image extraction | docs.rs |
pdfboss-write | PDF creation: COS object writer, content canvas, composed elements (text, paragraph, image, link), outlines, attachments, page labels, viewer preferences, XMP metadata and document assembly | docs.rs |
pdfboss-style | CSS-subset themes for document composition | docs.rs |
pdfboss-markdown | CommonMark+GFM composed into themed PDFs | docs.rs |
pdfboss-aio | Async, range-fetching PDF access: huge files, many documents, remote HTTP sources | docs.rs |
pdfboss-cli | The pdfboss command-line tool | docs.rs |
pdfboss-tui | Terminal explorer for PDF internals: element tree, object inspector, hex view, page preview and Markdown preview | docs.rs |
pdfboss-py | PyO3 extension module pdfboss._pdfboss, built with maturin | not on crates.io; ships as the pdfboss wheel |
A further workspace member, pdfboss-testkit, is an internal PDF fixture builder for the test suite; it is not published.
Where to start
- Reading a document:
pdfboss_core::Document(open,load, and their_with_passwordtwins), thenpage,page_count,metadata,version. - Text and markdown:
pdfboss_output::{extract_text, extract_markdown}; positioned styled spans viapdfboss_text::extract_spans. - Rasterizing:
pdfboss_render::{render_page, render_page_with_options, render_page_reporting}andPixmap::save_png; embedded images viaextract_page_images. - Creating:
pdfboss_write::{Pdf, Page, Canvas, Content}; see Creating PDFs. - Composing Markdown:
pdfboss_markdown::to_pdfwith apdfboss_style::Theme; see Markdown to PDF. - Async and HTTP sources:
pdfboss_aio::AsyncDocument(open,open_url,from_bytes); see Async and remote documents. - Element iteration:
pdfboss_core::Document::elements(ElementOpts), a lazy iterator over physical and logical elements, and the asyncAsyncDocument::elements, which returns anElementStream; see Exploring PDF internals.
The guide chapters carry compiled examples for each of these; the Quickstart has the shortest end-to-end one.
Limitations
Rendering is lenient and it says so: content pdfboss cannot read is skipped so the rest of the page still rasterizes, and every dropped or approximated item lands in a report. pdfboss render warns on stderr, the terminal explorer raises a notice, and the libraries return it through render_page_reporting (Rust) and Page.render_reporting() (Python). See Rendering pages for the reporting APIs.
The whole not-yet-supported list is two faces: /Symbol and /ZapfDingbats have no license-clean substitute, so they stay blank rather than borrowing an unrelated face's glyphs.
Fonts
Glyph painting is staged in tiers (embedded-only, all-embedded, full) selected with --fonts (CLI), the fonts parameter (Python) or RenderOptions::glyph_painting (Rust); the tiers are described in Rendering pages. What stays limited:
fullsubstitutes only non-embedded simple fonts, and a bold sans substitute is not visually distinct from regular weight.- Standard-14 advance widths come from the Adobe Core-14 AFM tables when a substitute is used, behind the PDF's own
/Widths.
CMaps and CID fonts
Type0 /Encoding CMaps resolve: the predefined ISO 32000 Table 118 CJK set is compiled in (behind the predefined-cmaps feature, on by default in the CLI and the wheel), embedded CMap streams parse the same way, widths key on the mapped CID, vertical text (WMode 1) advances by /W2//DW2 with the default position vector, and extraction maps CIDs to Unicode through the character collection when /ToUnicode is absent.
Deferred: vertical runs still extract as horizontal-schema spans, one per show operator, with x/y at the glyph origin.
JBIG2
JBIG2Decode covers the embedded stream format end to end: generic regions (all four templates, with TPGDON, arithmetic or MMR-coded), symbol dictionaries and text regions in both the arithmetic and the Huffman variant (refinement/aggregate-coded symbols and refined instance placements included), pattern dictionaries and halftone regions, generic refinement regions (both templates, with TPGRON) refining either the page or a retained intermediate region, intermediate regions of every type, and custom code table segments. Nothing in the standard's segment type table is refused; a malformed or truncated stream fails with a message naming what was wrong instead of rendering a blank.
Colour
Colour converts to sRGB. ICCBased spaces parse their embedded profile (v2 and v4; matrix/TRC and grayTRC models, and A2B0 lookup pipelines): a profile equivalent to sRGB keeps the exact device-RGB path, others transform per colour with Bradford adaptation from the D50 connection space, and a profile that will not parse falls back to the /N channel-count reduction. CalRGB, CalGray, and Lab convert through CIE XYZ the same way.
Only a profile's default transform is used (rendering intents are not switched), and DeviceN keeps a tint approximation.
JPEG 2000
JPXDecode implements ITU-T T.800 (JPEG 2000 Part 1); what it approximates it reports as a render warning rather than passing off silently.
ICC profiles embedded in the JPEG 2000 container are interpreted through the same ICC engine as ICCBased colour (a profile equivalent to sRGB or device gray keeps the exact device path, others transform per sample), and only a profile that will not parse falls back to the channel-count approximation. sYCC converts with the exact IEC 61966-2-1 Amd. 1 inverse.
Part 2 (ISO/IEC 15444-2) extensions are tolerated in the container but not decoded. Every output sample is normalized to 8 bits per channel with round-to-nearest, so sources deeper than 8 bits (the spec allows up to 38) still land on an 8-bit output grid.
Optional content
Optional content groups (PDF layers, ISO 32000 §8.11) are honored per the document's default configuration: rendering and text extraction skip layers it turns off, counting them on the reports' hidden counters.