Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

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) so render and tui can 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) so text reads 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
CrateResponsibility
pdfboss-coreParsing, object model, stream filters, document and page tree
pdfboss-textFonts, encodings, positional text spans
pdfboss-outputLayout analysis to plain text and Markdown
pdfboss-renderRasterization to RGBA pixmaps and PNG, embedded-image extraction
pdfboss-writePDF creation
pdfboss-markdownCommonMark+GFM composed into themed PDFs (pulls in pdfboss-style)
pdfboss-aioAsync, 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-render substitute-fonts: the bundled substitute faces.
  • pdfboss-core predefined-cmaps: the predefined CJK CMap set.
  • pdfboss-aio http: remote documents over HTTP range requests.
  • pdfboss-aio write: streaming created documents into tokio writers; ships TokioSink, which presents any tokio::io::AsyncWrite to Pdf::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 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 as n. .
  • 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

PropertyMeaning
textThe decoded text.
x, yDevice-space origin and baseline of the span.
end_xDevice-space x after the last glyph's advance.
sizeEffective font size.
fontFont resource name (e.g. "F1").
font_nameThe font's /BaseFont name verbatim, subset prefix included (e.g. "NZEVTB+Arial-BoldItalicMT"); empty when the file names the font nowhere.
page0-based index of the page the span came from.
bboxDevice-space box (x0, y0, x1, y1), y-up: origin to advance horizontally, the font's descent..ascent vertically.
bold, italicFrom FontDescriptor evidence, falling back to the /BaseFont name.
monospace, serifFontDescriptor /Flags FixedPitch and Serif.
underline, strikethroughA drawn ruling below the baseline / across the x-height band. See the caveat below.
riseThe text rise (Ts) the span was shown under: positive above the baseline, a superscript/subscript signal.
verticalWriting mode 1: the text advances downward.
invisibleShown under render mode 3 or 7, which paint nothing.
colorFill color as RGB in [0, 1]; None for pattern fills.

Three of these deserve honesty up front:

  • underline and strikethrough are 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.
  • invisible is 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.
  • color is None for 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, .bmp or .jpg (see Output formats). Any other extension is an error.
  • --fonts <FONTS>: which fonts to paint, one of embedded-only, all-embedded or full (see the tiers below). The default resolves to full when substitute faces are available (the compiled-in OFL set or --font-dir), otherwise all-embedded.
  • --font-dir <FONT_DIR>: a directory of substitute faces for --fonts full (e.g. an installed pdfboss-fonts package), named as listed under Substitute face files. Overrides the compiled-in OFL set.
  • --png-compression <PNG_COMPRESSION>: none, fast, default or best; 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-only paints 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 the pdfboss-fonts package; with neither font_dir nor that package available, fonts="full" raises ValueError.

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:

FamilyFiles
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:

formatpixelswhat it costs
png (default)RGBA 8-bit, filtered and deflatedthe compression level below
ppmbinary P6: P6 <w> <h> 255\n then RGB rows, top-downone packing pass
bmp24-bit BGR, bottom-up rows padded to four bytes, 54-byte headerone 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_painting selects the GlyphPainting tier: EmbeddedTrueTypeOnly, AllEmbedded or Full.
  • substitutes says where the Full tier's replacement faces come from. SubstituteSource::Builtin is the compiled-in OFL set, present only when the crate is built with the substitute-fonts Cargo feature; without that feature, Builtin degrades to no substitution, so Full behaves exactly like AllEmbedded. The probe pdfboss_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 default SubstituteSource::None substitutes nothing, so Full behaves like AllEmbedded until 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 is None; an asynchronous caller builds it itself, from AsyncDocument::oc_state, and leaving it None there renders every layer. See Async and remote documents.
  • cache: Option<Arc<RenderCache>> shares one RenderCache across a whole-document walk. It retains loaded fonts and parsed ICCBased colorspace outcomes across pages; None keeps 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 … EI sequences embedded directly in the content stream extract like any image XObject.
  • Stencil masks are skipped. An image with /ImageMask true paints the current fill color through a 1-bit stencil; it carries no pixels of its own, so there is nothing to extract.
  • /SMask becomes 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 /OC group 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, data length 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 to png, a JPEG SOI marker to jpeg, 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 one Vec<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 any AsyncByteSink; it hands the sink back unflushed. Vec<u8> is a sink, Immediate presents any std::io::Write as one, and pdfboss_aio::TokioSink (behind that crate's write feature) presents any tokio::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, h1h6, 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):

ConstructorSource
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_bytes uses 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 (feature http): the length comes from a HEAD request's Content-Length; each read is a GET with a Range: bytes= header. A 200 answer where 206 was asked for means the server ignores Range; 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_progress registers an observer for that one-time download (the CLI draws its stderr progress bar through it). A 206 body 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_fetch registers 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:

kindWhat it is
headerThe %PDF-1.x marker
objectOne indirect object (N G obj … endobj); ref carries (num, gen)
xrefOne cross-reference section, table or stream
trailerThe trailer dictionary
startxrefThe startxref pointer
eofThe %%EOF marker

Then the logical elements, in document order:

kindWhat it is
pageOne page; page carries the 0-based index
font, image, annotationA page's resources, under that page's index
content_opOne 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:

kindvalue()
headerthe version string, e.g. "1.7"
xref{"kind": "table" or "stream", "entries": int}
startxrefthe offset as int
font{"subtype": str, "base_font": str or None}
image{"width": int, "height": int}
annotation{"subtype": str}
content_opthe operator rendered as a string
eof, pageNone
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 an http(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 ignores Range costs 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). render and images are 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, .bmp or .jpg (default: page-N.png)
  • --scale <SCALE>: scale factor (default: 1)
  • --fonts <FONTS>: which fonts to paint, one of embedded-only (only embedded TrueType outlines, fastest), all-embedded (every embedded program), full (also substitute bundled faces for non-embedded fonts); the default resolves to full when substitute faces are available (the compiled-in OFL set or --font-dir), otherwise all-embedded
  • --font-dir <FONT_DIR>: directory of substitute faces for --fonts full; overrides the compiled-in OFL set
  • --png-compression <PNG_COMPRESSION>: none, fast, default or best (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 in render
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 --decode is 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 in json, including the --raw/--decode usage error when combined
  • --hex: hexdump results carrying a _span instead of printing JSON; colorized on a tty like hex, with NO_COLOR honored
  • -r: print string results raw, without quotes (like jq -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; --landscape swaps 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. size names a page size case-insensitively (a3, a4, a5, letter, legal; absent defaults to a4) and landscape (boolean) swaps width and height, both per page.
  • [[page.text]]: one line of text: value, at = [x, y] (the baseline origin), optional font and size.
  • [[page.paragraph]]: wrapped text: value, rect = [x0, y0, x1, y1], optional font, size, leading and align (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], optional width and height.
  • [[page.link]]: a clickable rectangle: rect plus exactly one of url or page (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

NameWhat it is
DocumentA loaded PDF, from a path or bytes; pages by index, the metadata property, extract_text, extract_markdown, render_pages, elements, spans
PageOne page: geometry (width/height/rotation and the five boxes), extract_text, extract_markdown, spans, render, render_reporting, extract_images
AsyncDocumentThe async twin of Document, opened from a path, bytes, or an HTTP URL via range requests; data-fetching methods are coroutines
AsyncPageThe async twin of Page; attributes are synchronous, extraction and rendering are coroutines
ElementOne physical or logical element of a PDF (kind, span, ref, page, lazy value()), yielded by elements
ElementIterLazy sync iterator over elements; each step releases the GIL
AsyncElementIterAsync iterator over elements; each step is a coroutine
SpanOne styled text span: text, position, bbox, font identity, bold/italic/monospace/serif, underline/strikethrough, rise, vertical, invisible, color
SpanIterLazy sync iterator over a document's spans, buffering one page at a time
AsyncSpanIterAsync iterator over a document's spans
PageImageOne embedded image extracted from a page: native width/height and PNG-encoded data
PdfErrorThe 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:

NameWhat it is
PdfA document under construction; composes pages, attachments and page labels (appended) and the singleton Metadata, Outline and Viewer slots; save(path) and to_bytes() serialize
PageOne page (size names a size case-insensitively, default "a4"; landscape swaps width and height); composes Text, Image, Link, Paragraph or any draw object
TextOne line of text: value, at=(x, y) baseline origin, font (default Standard14.HELVETICA), size (default 12.0), optional (r, g, b) color
ParagraphWrapped, aligned text: text, rect, font, size (default 11.0), leading (default derived from size), align of left, center, right or justify
ImageA placed raster: data as a path string or bytes, at, optional width/height; either source is read and decoded at save/to_bytes time
LinkA clickable rectangle: rect plus exactly one of url or page (0-based)
BookmarkOne outline entry: title, page, keyword-only children; nests by construction
OutlineThe bookmark panel, Outline(*bookmarks); a singleton Pdf slot
AttachmentAn embedded file: name, data, optional mime and description; carries no dates
PageLabelOne page-numbering range: first_page (0-based), optional style (decimal, roman-upper, roman-lower, letters-upper, letters-lower), prefix, start_at (default 1)
ViewerOpening preferences: layout, mode, open_to; a singleton Pdf slot
Metadata/Info text fields: title, author, subject, keywords, creator, producer; a singleton Pdf slot
Standard14The fourteen standard faces as SCREAMING_SNAKE members: HELVETICA, TIMES_BOLD_ITALIC, ZAPF_DINGBATS, …
CanvasThe 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.

CrateResponsibilityDocs
pdfboss-corePDF syntax, objects, filters, cross-references and document model (ISO 32000)docs.rs
pdfboss-textFont loading, encodings, ToUnicode CMaps and text extractiondocs.rs
pdfboss-outputLayout analysis and output rendering: plain text and markdowndocs.rs
pdfboss-encodingShared font encoding tables and glyph-name mappings (ISO 32000 Appendix D)docs.rs
pdfboss-jpxCleanroom JPEG 2000 (JPXDecode) decoder (ITU-T T.800)docs.rs
pdfboss-iccCleanroom ICC profile parser and colour transform (ICC.1:2010)docs.rs
pdfboss-renderPage rasterization to RGBA pixmaps and PNG, plus embedded-image extractiondocs.rs
pdfboss-writePDF creation: COS object writer, content canvas, composed elements (text, paragraph, image, link), outlines, attachments, page labels, viewer preferences, XMP metadata and document assemblydocs.rs
pdfboss-styleCSS-subset themes for document compositiondocs.rs
pdfboss-markdownCommonMark+GFM composed into themed PDFsdocs.rs
pdfboss-aioAsync, range-fetching PDF access: huge files, many documents, remote HTTP sourcesdocs.rs
pdfboss-cliThe pdfboss command-line tooldocs.rs
pdfboss-tuiTerminal explorer for PDF internals: element tree, object inspector, hex view, page preview and Markdown previewdocs.rs
pdfboss-pyPyO3 extension module pdfboss._pdfboss, built with maturinnot 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_password twins), then page, page_count, metadata, version.
  • Text and markdown: pdfboss_output::{extract_text, extract_markdown}; positioned styled spans via pdfboss_text::extract_spans.
  • Rasterizing: pdfboss_render::{render_page, render_page_with_options, render_page_reporting} and Pixmap::save_png; embedded images via extract_page_images.
  • Creating: pdfboss_write::{Pdf, Page, Canvas, Content}; see Creating PDFs.
  • Composing Markdown: pdfboss_markdown::to_pdf with a pdfboss_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 async AsyncDocument::elements, which returns an ElementStream; 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:

  • full substitutes 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.