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

nupdf

nupdf is a PDF engine for AI agents, in Rust: a command-line tool, an MCP server and a library, built to be called by a model rather than read by a person.

  • Correct. Each subsystem is checked file by file against a pinned build of MuPDF, and parity is ratcheted in CI, so a file reads in nupdf as it reads in MuPDF.
  • Safe. No unsafe code, no panics on input, explicit limits on every loop over a file’s contents, and continuous fuzzing. A hostile file can fail to open; it cannot crash or hang the agent that opened it.
  • Parallel. A parsed document is shared across threads, so its pages are processed at once.
  • Agent-first. Every command prints one versioned JSON report with stable ids for each span of text, diagnostics as data instead of log lines, budgets and cursors for long files, and a redaction that reads its own output back to prove nothing is left.

Who it is for

  • Agents and the people who build them. An agent that gets a PDF (an invoice, a contract, a scanned form, a bundle of statements) can triage it, read it in reading order or by position, find text and get its boxes, render a page only when text cannot answer, fill forms, compare versions, and redact with a check that the redaction worked. Claude Code and MCP shows how to wire it in.
  • Scripts and pipelines that want what is in a PDF as JSON, with exit codes that mean something (see the agent interface).
  • Rust programs that want a memory-safe PDF reader and writer: see as a library.

Where to go next

License

nupdf is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later). It follows MuPDF’s design, algorithms and behavior closely enough to be a derivative work of MuPDF, by Artifex Software, Inc., and is distributed under MuPDF’s license. It is an independent project, not affiliated with or endorsed by Artifex. The NOTICE lists what nupdf derives from and the third-party code and data it includes, with their licenses.

Install

nupdf is one binary, nupdf, from the nupdf-cli crate. It needs Rust 1.92 or newer to build (rustup installs it).

From crates.io, once 0.1.0 is published:

cargo install --locked nupdf-cli

From the repository, at any time:

cargo install --locked --git https://github.com/raspao-studio/nupdf nupdf-cli

Check it runs:

nupdf --version

Nothing else is needed. The one exception is ocr, which runs Tesseract for scanned pages and finds it on PATH (or where --tesseract says).

First commands

Every command has the form nupdf <command> <file> [options] and prints one JSON report: its schema (nupdf.info/v1), the file’s doc_id (a hash of its bytes), the command’s fields, and diagnostics, the problems nupdf found in the file and worked around. Errors are JSON too, on stderr. The exit code says what happened: 0 ok, 2 usage, 3 bad input, 4 verification failed, 5 a limit was hit. --pretty indents the JSON for people; agents do not need it. The agent interface has the whole contract.

The examples use invoice.pdf; any PDF will do.

The guide

nupdf guide

A page of plain text written for models: the conventions above, which command to reach for at each step of a task, and every command by name. An agent reads it once at the start of a task. nupdf guide extract gives one command’s options in full, as does nupdf extract --help.

Triage

nupdf info invoice.pdf --pretty

The facts an agent decides with before reading: page count, PDF version, encryption and permissions, whether the file was repaired, its metadata, and which pages have text and which look scanned (text.scanned_pages, the ones that need ocr).

Read

nupdf extract invoice.pdf --format markdown
nupdf extract invoice.pdf --format layout

markdown (or text) is the text in reading order. layout keeps each line where it sits on the page, a row per line, so a value stays beside its label: the format for invoices, forms and tables, where position carries meaning. Text formats go to stdout and the report to stderr.

--format json gives every block, line and span with its id (p3.b2.l4), box and font. For long files, --max-chars 8000 stops at a budget and the report’s next_cursor continues from there (--cursor <next_cursor>); --pages 3-5 reads only some pages.

Find

nupdf search invoice.pdf "total due" --options ignore-case

Each hit has its page, the text matched, the quads it covers in page space (points, origin top left) and the ids of the spans it falls in, which other commands take. --options regexp searches by regular expression.

Look

nupdf render invoice.pdf -o pages --pages 1 --dpi 72

Writes pages/page-0001.png and reports where each image went and its size. An image costs a model far more than text does, so render only what text cannot answer: a chart, a signature, a layout to check.

Redact, then verify

nupdf redact invoice.pdf -o redacted.pdf --preset pii
nupdf verify redacted.pdf --text "jane@example.com"

redact removes what it is given (--text, --regex, --preset email|phone|card|ssn|iban|pii, --region, or --id from a search) everywhere it appears: page text, images and line art under it, annotations, form fields, metadata, bookmarks, attachments and earlier revisions. Then it reads its output back and searches it again. Its report counts each target’s hits before and after; if anything is left it exits 4. The input is never changed.

verify checks any file, redacted by nupdf or not, for a text (--text, --regex) across all of those places, and exits 4 if it finds it. verify --against takes a redact report and checks its targets and areas. The redaction contract says exactly what is checked.

Everything else

nupdf guide lists all the commands: splitting, merging and rearranging pages, metadata, forms and annotations, outlines and links, tables, OCR for scanned pages, sanitize for untrusted files, diff between versions, and batch to run any command on many files at once.

Claude Code and MCP

There are two ways to give an agent nupdf: as a skill, where the agent runs the CLI from its shell, or as an MCP server, where each command is a tool. Both run the same commands and return the same JSON. Install nupdf first (Install).

As a Claude Code skill

The skill is the guide (nupdf guide) with the name and description Claude Code reads to decide when to use it: whenever a task involves a PDF. The repository has it at skills/nupdf/SKILL.md, and the binary prints the same file, so the skill always matches the version installed:

mkdir -p ~/.claude/skills/nupdf
nupdf guide --skill > ~/.claude/skills/nupdf/SKILL.md

That installs it for every project. For one project only, write it to .claude/skills/nupdf/SKILL.md in that project instead. Claude Code then runs nupdf through its shell when a task calls for it. Regenerate the file after upgrading nupdf.

As an MCP server

nupdf mcp is a Model Context Protocol server over stdin and stdout. Add it to Claude Code with:

claude mcp add nupdf -- nupdf mcp

or, to share it with a project, check in a .mcp.json at the project’s root (claude mcp add --scope project writes one):

{
  "mcpServers": {
    "nupdf": {
      "command": "nupdf",
      "args": ["mcp"]
    }
  }
}

Other MCP clients take the same command and arguments.

What the server offers:

  • A tool for each command but mcp, batch and guide, its arguments the command’s options by name (file, pages, format, max_chars, …), read from the CLI’s own definition so they never fall out of step. The global options come with every tool: password, the limits, and a timeout in seconds.
  • The guide as its instructions. A client gets it when it connects, so the model knows where to start without calling anything.
  • Many files in one call. Give file a list of paths, directories or globs and the tool runs on each, one line per file with its report, then a summary.
  • The CLI’s reports, byte for byte. A result carries the report as text and, for JSON reports, as structuredContent; a failure is a tool error carrying nupdf’s error report.
  • Bounded calls. Each call stops after --call-timeout seconds (300 by default) or its own timeout, whichever is sooner, and a cancelled call stops where its work next checks; nothing it would have written is written. A report over --max-output-bytes (8 MB by default) fails with limit.output and points to the budgets (pages, max_chars, max_items). Set either on the command line: claude mcp add nupdf -- nupdf mcp --call-timeout 60.

Files are paths on the machine the server runs on. The server reads and hashes each file once while it stays the same, so repeated calls on one file are cheap.

Which to use

The agent eval runs both on the same tasks: both passed every task, and the MCP server took fewer tokens to do it. The skill suits an agent that already works in a shell; the server suits a client without one, or a setup where tool calls are easier to permit and audit than shell commands.

As a library

The engine is the nupdf crate; the CLI is a thin layer over it. Add it to a project’s Cargo.toml, from crates.io once 0.1.0 is published:

[dependencies]
nupdf = "0.1"

or from the repository:

[dependencies]
nupdf = { git = "https://github.com/raspao-studio/nupdf" }

This program opens a file, extracts the structured text of every page on four threads, searches each page, and prints what the file got wrong:

use nupdf::{Document, parallel, search, stext};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let path = std::env::args().nth(1).ok_or("usage: scan <file.pdf>")?;
    let doc = Document::open(std::fs::read(path)?)?;
    let count = doc.page_count().map_err(|e| e.0)?;
    let pages: Vec<i32> = (0..count).collect();

    // Structured text of every page, on up to 4 threads, in page order.
    let opts = stext::Options::default();
    let results = parallel::extract(&doc, &pages, opts, Some(4));
    for (i, page) in results.into_iter().enumerate() {
        let page = page?;
        let hits = search::search_page(&page, "hello", search::options::IGNORE_CASE)?;
        println!("page {}: {} hit(s)", i + 1, hits.len());
        print!("{}", page.text());
    }

    // What was wrong with the file, as data rather than as a failure.
    for d in doc.diagnostics().snapshot() {
        eprintln!("{}: {}", d.code, d.message);
    }
    Ok(())
}

A few things to know:

  • A Document is shared across threads. It is parsed once and pages are worked on at once; parallel::extract with None instead of Some(4) runs the pages one after another, as MuPDF does, with the same results.
  • Pages are 0-based in the library (doc.page(0) is the first), 1-based in the CLI.
  • Limits. Document::open uses the default Limits; Document::open_with(data, limits) sets the largest decoded stream, xref table and the rest, as the CLI’s --max-* options do.
  • Diagnostics are data. A damaged file opens if MuPDF would open it, and what nupdf repaired or skipped is in doc.diagnostics(), with the same stable codes the CLI reports.
  • The modules follow MuPDF’s. stext is fz_stext, search is MuPDF’s search, redact, write, form and the rest likewise; the architecture maps each module to the MuPDF file it comes from.

The library’s API is not yet stable: until 1.0 a minor release may change it. The CLI’s JSON reports are the stable interface, versioned per schema (agent interface).

Results

What nupdf has been measured to do, against MuPDF (the reference it is checked against) and against the other ways an agent can handle a PDF. Each section names the command that reproduces it and the document with the full numbers. Measured on 2026-11-13 on an Apple M-series Mac, against MuPDF 1.28.6.

In one paragraph

nupdf reads PDFs as MuPDF does, in Rust with no unsafe code. On the standard tier, 5,142 test files, 91 of its 97 checks against MuPDF agree on at least 99% of the files and the median check on 99.88%. Across 1.5 million runs on 100,000 files from the open web, it never crashed. Its library is faster than PDF Oxide’s on every operation compared, in geometric mean and in total, and its commands are faster than MuPDF’s on most. In an agent eval of 30 tasks, agents using nupdf solved every task in every run, the only arm to do so. Over MCP they spent fewer tokens and less time than agents using any other tool. Over the CLI they spent slightly more tokens than agents writing PyMuPDF or pypdf scripts.

Parity with MuPDF

cargo xtask harness --tier standard runs each check on every file with both engines and compares the results. The tier is 5,142 files from the pdf.js, PDFium, qpdf, veraPDF, pdf-differences and other public test suites. A check’s parity is the share of matches among the files at least one side reads.

CheckWhat is comparedMatchesParity
objects, info, pages, streamsevery object; document and page facts5,122–5,123 of 5,12399.98–100%
text, stext, searchtext as mutool draw -F txt, structured text with boxes and fonts, search hits5,123 of 5,123100%
fonts, tables, attachments, outline, linksas MuPDF lists themall100%
annots, forms, signatureseach annotation’s getters, each widget, each signature’s digest checkall100%
imageseach image’s properties5,105 of 5,12399.65%
clean, flattenthe written file, byte for byte5,020 and 5,011 of 5,04199.58% and 99.40%
mergethe written file5,123 of 5,123100%
redact-textthe text left after redaction5,052 of 5,06699.72%
render-72, render-150each page’s pixels within the threshold (a channel off by more than 8, on at most 0.5% of pixels)5,070 and 5,069 of 5,123 files; 99.75% of 19,790 pages at 72 dpi98.97% and 98.95% of files

All 97 checks, including colour-managed rendering, round trips and every editing command: TESTING. What nupdf does differently on purpose is in DECISIONS: D5 sets the rule that the default behaviour is MuPDF’s and anything better is an option.

On the open web (WILD-TIER), on an evenly spaced 1,000 of 100,000 unsanitized files:

  • Every check but rendering is at 99.3% or better.
  • Rendering at 72 dpi matches on 98.7% of pages (95.6% of files).
  • The pages left are stroked text, which MuPDF draws with FreeType’s stroker (97 pages), and CJK text in fonts the file does not embed, which MuPDF draws with a 25 MB font nupdf does not carry (70 pages).

Safety

  • No unsafe. The workspace forbids it (unsafe_code = "forbid"). The parsers it depends on (zlib-rs, hayro-jbig2, hayro-jpeg2000, moxcms) are pure Rust.

  • No crash on the open web. cargo xtask crashes --tier wild ran 15 commands on each of 100,000 files from SafeDocs’ CC-MAIN-2021-31 crawl, malware included: 1,500,000 runs.

    • No run ended in a signal.
    • The 62 panics were one bug, now fixed.
    • Every run that took over a minute has been profiled and fixed, or finishes.
  • Fuzzed. 19 fuzz targets, one per parser surface, run in CI. The files for OSS-Fuzz are ready for when the repository is public.

  • Tested. 95.6% line coverage of the library (cargo llvm-cov).

  • Redaction that is checked. Results:

    • On the adversarial suite and the redaction tier, no leaks, as judged by nupdf verify, MuPDF’s extraction and pdftotext.
    • Files seeded to leak are caught by verify.

    What nupdf promises and what it does not: THREAT-MODEL.

Speed

cargo xtask bench, measured on main at ca67177 on a busy Apple M5 Max: medians of 3 runs as processes, of 5 in process; one thread unless stated (BENCHMARKS). Ratios are the other tool’s time over nupdf’s: above 1x, nupdf is faster.

Against PDF Oxide 0.3.78, library against library: both crates in one Rust program, timing only the work on a file already in memory. Geometric mean, with the ratio of the summed times in brackets:

Operation200 small files30 long documents
open2.21x (1.03x)5.85x (7.35x)
page sizes2.49x (1.53x)11.45x (9.94x)
text1.40x (2.22x)1.82x (2.57x)
render (72 dpi, pixels only)1.58x (3.20x)3.08x (7.36x)
tables1.27x (1.70x)1.55x (1.34x)
annotations2.41x (1.44x)22.03x (8.10x)

Against MuPDF, per command, start included:

  • Small files (200): nupdf is faster on every command, 1.24x (clean -z) to 2.02x (tables).
  • Documents of about 100 pages (30): faster on reading, extraction, tables, rendering and annotations, 1.4x to 3.8x, and up to 6.7x with eight threads. Slower on two things:
    • info, 0.26x: it scans every page for scanned pages, which MuPDF’s info does not: up to 0.17 s a document, against about 5 ms.
    • Writing long documents: clean -z 0.35x and merge 0.85x; clean, clean -gggg and split are about even (#86).

Per call against PDF Oxide’s own CLI: nupdf is 1.1x to 2.2x faster on small files, and 1.86x on long documents’ text. It is slower on long documents’ info (0.24x, the same scan) and on writing their pages as PNG files: 0.73x in geometric mean, though 3.06x in total, where a few documents take PDF Oxide seconds. nupdf draws faster; the time goes to its PNG encoder (#81).

Failures: PDF Oxide fails 4 to 7 of the 200 standard files, depending on the operation, and nupdf one (which MuPDF also fails). On 7 of the 30 long documents its page-size call fails on boxes the pages inherit.

Agents

Thirty graded tasks, five runs of each per arm, one small-model agent (Claude Haiku 5.5) per run, headless in Claude Code with the arm’s tool only: 1,045 runs (eval/REPORT.md). The tasks:

  • find a sentence and its page, check citations, find a phrase in 352 pages;
  • read invoices, forms and a printed claim form, sum a bundle of 150 receipts;
  • triage damaged and locked files, open one with a password;
  • merge, split at bookmarks, rotate, fill a form, sanitize;
  • redact a secret and PII across bundles;
  • read comments, links, attachments, signatures, page sizes and scanned pages;
  • compare two versions, read two columns in order.

The graders use pdftotext, qpdf and MuPDF, never nupdf.

ArmPassedMedian tokens, passed runsMedian wall time
nupdf MCP150 / 1508,98813 s
nupdf CLI150 / 15014,57816 s
pymupdf4llm (with PyMuPDF), in Python147 / 15012,80523 s
pypdf and pdfplumber, in Python145 / 15011,82321 s
PDF Oxide, in Python108 / 15020,85830 s
PDF Oxide’s MCP server79 / 10515,43620 s
pymupdf4llm-mcp38 / 1054,61723 s
the model reading page images75 / 8517,40212 s

The arms that cannot write a PDF skip the tasks that do.

Compared task by task over the tasks both solved:

  • nupdf over MCP spent the least. The other arms used 1.16x (pymupdf4llm) to 2.14x (PDF Oxide in Python) its tokens, and 1.13x to 1.89x its wall time.
  • The CLI spent slightly more tokens than two arms: pymupdf4llm used 0.86x its tokens and pypdf 0.95x. It was still faster than both.
  • Tool calls were even, at a median of seven a run.

Where nupdf falls short

  • Tokens over the CLI. An agent using the CLI spends slightly more than one writing PyMuPDF or pypdf scripts. That is the gap to close for agents that do not use MCP.
  • Rendering not yet ported:
    • stroked text (FreeType’s stroker);
    • CJK text in fonts the file does not embed;
    • FreeText annotations and form fields with rich text, which MuPDF lays out with its HTML engine.
  • Slower than MuPDF or PDF Oxide in places:
    • writing long documents: on the 30 long ones, clean -z runs at 0.35x MuPDF’s speed and merge at 0.85x (#86);
    • info on long documents, 0.26x MuPDF’s: its scan for scanned pages takes up to 0.17 s a document, against 5 ms for MuPDF’s info, which does not scan;
    • PNG output: on long documents PDF Oxide’s CLI writes a document’s PNGs faster in geometric mean (nupdf at 0.73x), because of nupdf’s PNG encoder (#81);
    • a few files from the open web: sanitize saves, reparses and verifies its output, so it does more than mutool clean and takes longer (24 s against 9 s on the worst file seen), and tables takes 20 to 52 s on two files, with MuPDF’s own algorithm (#81).
  • One machine, one model. The speed figures come from a busy Apple M-series Mac, and the agent figures from one model. Five runs per task damp the model’s variance (a task’s tokens vary by a median 1.6x between its runs) but do not remove it.
  • Not yet public. Until the repository is public, there is no continuous fuzzing on OSS-Fuzz and no outside review.

What nupdf can do for agents that its rivals cannot

Measured on 2026-11-11. The question: which one to three things can nupdf do for AI agents that MuPDF, pypdf, pdf_oxide, LiteParse and Docling could not do without changing their architecture?

Answer: on this evidence, none that an agent felt. Two properties of nupdf survive a reading of the rivals’ source as things they cannot add in a quarter (a memory-safe core, and one parsed document shared across threads). Neither changed an outcome in 50 graded agent runs or in the two experiments built to test them. The arm built on PyMuPDF passed every task. What nupdf did do, measurably, is let an agent finish redaction with a third to a half of the code the Python arms wrote, and never report a leaking file as clean; a rival could ship both in weeks.

How to rerun

python3 eval/agents/setup.py --corpus ~/.cache/nupdf/corpus --redaction corpus/redaction --smoke corpus/smoke --mutool MUTOOL --out DIR
python3 eval/agents/make_tasks.py --out DIR && python3 eval/agents/make_more.py --out DIR --mutool MUTOOL
python3 eval/agents/prepare.py --out DIR --venv VENV --lit LIT --nupdf NUPDF
# give each DIR/runs/<arm>-<task>/PROMPT.txt to a fresh agent, then:
python3 eval/agents/grade.py --out DIR --mutool MUTOOL
python3 eval/agents/metrics.py --map agents.tsv --transcripts TRANSCRIPTS
python3 eval/hostile/make.py HDIR && python3 eval/hostile/run.py --files HDIR --nupdf NUPDF --mutool MUTOOL --python VENV/bin/python --lit LIT
python3 eval/parallel/run.py --pdf freeculture.pdf --nupdf NUPDF --python VENV/bin/python

Graders use pdftotext, pdfinfo, qpdf and MuPDF, never nupdf. Results are in eval/*/results/.

1. The agent eval

Six arms, nine tasks, one small-model agent (claude-haiku-5-5) per arm and task, each told it may touch PDFs with its arm’s tool only. 50 runs; four arm and task pairs were not run because the arm cannot write a PDF at all.

Armlocatequotescorpushiddenlongdocdamagedmergeredactredact2PassedTool callsContext addedOutput tokens
native (the model reads the PDF)passpasspasspasspassgave upn/an/an/a5 / 69.534,0293,122
python (pypdf, pdfplumber)passpasspasspasspass0.25passpasspass8 / 97.714,6335,757
pymupdf4llm (with PyMuPDF)passpasspasspasspasspasspasspasspass9 / 98.314,4844,650
pdf_oxide 0.3.78passpassgave uppasspass0.751.00*0.800.404 / 911.926,6858,153
LiteParse 2.18.0passpasspasspasspass0.75gave upgave upn/a5 / 87.411,7312,661
nupdfpasspasspasspasspass0.88passpasspass8 / 98.318,4963,212

Scores are the grader’s, 0 to 1; “pass” is 1. Tool calls, context added and output tokens are means per run. Tokens are the API’s own usage figures: “context added” is the last request’s input minus the first’s, which leaves out the system prompt every arm shares. * the agent wrote a correct file and then reported failure because it could not read its own result back.

The tasks: find a sentence and its page in a 40-page file (locate); check three citations, one altered and one on the wrong page (quotes); say which of twelve files mention a word (corpus); summarise a memo that carries an instruction in invisible text (hidden); find a phrase in a 352-page book (longdoc); triage nine damaged or locked files (damaged); merge pages and set a title (merge); remove a secret from five and then ten files that hide it in different places (redact, redact2).

What the table does not show:

  • Every arm ignored the hidden instruction. The model, not the tool, did that. Hidden-text flagging would not have changed a result here.
  • Every arm found the phrase in 352 pages, the native arm included, at 62,292 tokens of added context against 9,734 to 22,708 for the tools.
  • nupdf was not the cheapest in context. The two Python arms and LiteParse added less. nupdf’s JSON is larger than a Python script’s printed answer.
  • nupdf wrote the least code. On the two redaction tasks its agent produced 3,566 and 4,731 output tokens. The pypdf arm produced 11,249 and 16,854, the PyMuPDF arm 7,998 and 11,017, and pdf_oxide 15,162 and 23,045. All of nupdf’s and both Python arms’ outputs were clean by the independent grader.

2. Failures, coded by cause

11 of 50 runs did not pass. A run can carry more than one cause.

CauseRunsContext spentTool callsWhere
C1. The tool cannot write, or has no call for the thing4, and the 4 pairs not run113,42047LiteParse merge and redact; pdf_oxide redact (dropped every annotation) and redact2 (no call for outlines, page labels, a form XObject or structure text)
C2. A damaged file is read worse than the reference readers read it586,41656pypdf wrong on 6 of 8 files; LiteParse and pdf_oxide on 2 each; the native arm had all nine rejected; nupdf’s agent on 1
C3. The agent could not confirm its own result3111,28844pdf_oxide: gave up on a correct merge and on a correct search, and reported two leaking redactions as clean

Notes on the coding:

  • C3 is the only cause that produced a wrong answer presented as right: two pdf_oxide redactions were listed as clean while the secret was still in the file’s bytes.
  • nupdf’s one miss is an agent reading honest output badly. nupdf pages says the file declares 3 pages and only 1 can be found; the agent answered 1, and poppler, qpdf and MuPDF all say 3.
  • The damaged fixtures carry labels such as “No xref” as their page text. The pypdf agent took those for damage residue and discarded them, so its 0.25 overstates pypdf’s weakness. That is a flaw in the fixtures.

3. Which constraints are architectural

From reading each rival’s source. Port cost is a judgement: what it would take a competent maintainer to add.

CapabilityMuPDF 1.28.6, PyMuPDFpypdfpdf_oxideLiteParseDocling
Ids for text that another call acceptsBlock ids are per parse (include/mupdf/fitz/structured-text.h:448). WeeksNo text model (pypdf/_page.py:2200). A quarterPage and index only (src/layout/text_block.rs:24). DaysNone (crates/liteparse/src/types.rs:62). DaysPositional references. Weeks
Redaction that re-checks its outputRemoves content, no check (source/pdf/pdf-clean.c:1199). WeeksNo redaction call. A quarterRemoves content, no check (src/redaction/). WeeksRead-only. A quarterRead-only. More than a quarter
Flagged truncation, cursorsNone. DaysNone. DaysNone. DaysNone. DaysPage limits exist
Same bytes every timeRandom /ID and IVs (source/pdf/pdf-write.c:2198). DaysRandom /ID (pypdf/_writer.py:1266). DaysTimestamps (xmp_metadata.rs:318). DaysNo writerNo writer
Says which text is invisibleDetectable from flags. DaysNot tracked. WeeksTracked, not exposed (graphics_state.rs:239). WeeksDropped, not exposed (extract.rs:870). DaysHas a visible-text call
One document used from several threads“No simultaneous calls” on a context (docs/reference/c/overview.md:194); PyMuPDF “does not support running on multiple threads” (docs/recipes-multiprocessing.rst:19). More than a quarterNo locks found. WeeksSend + Sync behind one mutex (src/document.rs:379). WeeksOne process-wide lock on PDFium (crates/pdfium/src/library.rs:24). More than a quarterOne lock on PDFium
Memory-safe parsing coreC. More than a quarterPython, safeRust, about 5 unsafe outside FFIC++ (PDFium). More than a quarterC++ backends. More than a quarter

By the rule “discard anything a rival could ship in a quarter”, ids, verified redaction, budgets, determinism and hidden-text flags are all discarded. Two rows survive, and neither survives against pdf_oxide, which is also Rust and also shares a document across threads.

CVE records support the memory-safety row for the C and C++ cores: MuPDF CVE-2026-3308 and CVE-2026-25556 (both fixed before the pinned 1.28.6), PDFium CVE-2026-2648, CVE-2026-6305 and CVE-2026-10002. pypdf’s recent CVEs are resource exhaustion, not memory corruption. These were collected by web search and not checked against NVD entry by entry.

4. The two surviving claims, tested

Claim A: on hostile input, nupdf neither crashes nor exceeds a fixed memory bound, and the C-core tools do one or the other. It would be disproved if no rival crashed, or if a rival stayed lower. Seven files built to exhaust a reader (eval/hostile/make.py), each tool asked for all the text, one process each.

Result: disproved on this set. No tool was killed by a signal on any file. On a content stream that inflates to 512 MB, MuPDF peaked at 5 MB and nupdf at 545 MB, because nupdf holds the decoded stream. pdf_oxide peaked at 1,917 MB and LiteParse at 1,152 MB; on ten million text operators LiteParse reached 4,908 MB. pypdf exited with an error on three files. Memory safety remains true of nupdf’s code, but this experiment found no input where it changed what happened, and found one where nupdf is 100 times worse than the engine it follows.

Claim B: sharing one parsed document across threads makes nupdf faster on a long file than a tool that cannot. It would be disproved if threads gave no speed-up, or if separate processes did as well. Text of all 352 pages of Free Culture, median of five runs on an 18-core machine.

Result: no speed-up to claim. nupdf took 0.144 s on one thread and 0.137 s on eight. PyMuPDF took 1.041 s in one process and 2.451 s in eight. nupdf is seven times faster than PyMuPDF started from Python, on one thread; the threads add nothing at this size.

No new nupdf code was written for these: both properties already exist, and neither experiment gave a reason to build on them.

5. Recommendation

There is no architectural wedge to build the product on, and the site should not claim one.

What the evidence supports instead is an ergonomic lead that has to be kept by shipping, not by structure:

  1. Lead with “the agent does not have to write or check the code”. The redaction result is the one place nupdf beat every arm on a measured cost with equal correctness, and verify is why its agent never guessed. PyMuPDF can match it in weeks, so it is a head start, not a moat.
  2. Fix the two things this found. The 545 MB decoded stream contradicts the “bounded on hostile input” story. pages output led an agent to the wrong page count.
  3. Stop building toward thread scaling and hostile-input claims as selling points until a measurement shows a case where they change an outcome. Stop describing ids, budgets, determinism and hidden-text flags as things rivals cannot do.
  4. Treat PyMuPDF as the rival, not pypdf. It passed everything here and is what pymupdf4llm agents already use.

What this did not establish

  • One run per arm and task, one model, no repeats: differences of a single task are noise.
  • A stronger or weaker model may shift every row.
  • The nupdf binary was built from an unmerged working tree that has redact and verify; main does not have them yet.
  • The source-code findings and port costs come from two reading passes; five citations were spot-checked and held. Docling’s backends and document model were not read.
  • The native arm is one agent harness’s PDF reader, not every product’s.
  • Agent transcripts are not committed: they hold local paths and system prompts. results/metrics.json has what was taken from them.

Token follow-up (M10, 2026-11-12)

The pilots showed nupdf’s MCP arm calling once per file (54 calls on the PII task) and never seeing the guide. M10 changed the MCP server: its instructions became the guide, and file takes a list, run through batch in one call. Measured on Haiku 5.5, the 11 tasks, three runs of each task per arm, against the M8 build:

BuildArmSolvedMedian tokens per solved taskCalls per run
M8CLI33/3314,5159.2
M10CLI33/3316,69910.0
M10, M8-length guide and instructionsCLI33/3316,18510.7
M8MCP33/3312,55717.9
M10MCP33/3315,67112.6
M10, M8-length guide and instructionsMCP33/3314,92513.5
  • File lists did what they were for: MCP calls per run fell by about 30%, and by two thirds on the many-file tasks.
  • Tokens did not fall; they rose by 10–25%. The commands’ own output is the same size as M8’s (info adds 60–150 bytes). The growth is in what agents choose to do: they render pages and read the images more often (Read 6.7k characters per run at M8, 15.2k now), most of it on the invoices task, and read a little more help.
  • Shortening the guide and the MCP instructions back to M8’s length recovered little, so their length is not the main cause.
  • Three runs a task is not enough to tell an effect of the build from the model’s variance on these numbers; one run a task, as in the pilots, is far from enough (the first M10 run looked 40% worse on the CLI arm, the three-run median 15%).

Next to try: make the text route clearly cheaper than rendering for layout questions (the invoices task reads fields by position), and measure with five or more runs a task.

The full eval (M10, 2026-11-13)

Thirty tasks, eight arms, five runs of each task per arm, one small-model agent (claude-haiku-5-5) per run: 1,045 runs. The nupdf binary is main at 1abb7e5 (with extract --format layout) plus raspao-studio/nupdf#62 (radio groups filled by name, files on FileAttachment annotations, columns drawn line by line across). Rerun with the commands at the top, then python3 eval/agents/tables.py DIR1 ... DIR5; the graded results are in results/m10/r1 to r5.

The arms:

  • native: the model reads the pages as images.
  • python: pypdf and pdfplumber, the default Python PDF skill.
  • pymupdf4llm: the library with PyMuPDF.
  • pymupdf4llm-mcp 1.28.0: its MCP server.
  • pdf_oxide: PDF Oxide 0.3.78’s Python bindings.
  • pdf_oxide-mcp: PDF Oxide’s MCP server 0.3.78.
  • nupdf: the CLI.
  • nupdf-mcp: nupdf’s MCP server.

The two MCP servers of other tools only extract, so like the native arm they skip the nine tasks that write a file. LiteParse, in the pilots, is left out: it is no arm of the plan’s.

ArmRunsPassedMean scoreGave upMedian tokens, passedMedian callsMedian wall s
native8575 (88%)0.922417,402412
python150145 (97%)0.972111,823721
pymupdf4llm150147 (98%)0.986012,805723
pymupdf4llm-mcp10538 (36%)0.426554,617723
pdf_oxide150108 (72%)0.8482120,8581030
pdf_oxide-mcp10579 (75%)0.7932015,4361020
nupdf150150 (100%)1.000014,578716
nupdf-mcp150150 (100%)1.00008,988713

Tokens are the run’s context added plus its output tokens; medians are over passed runs. Wall time is the session’s, model time included.

Tasknativepythonpymupdf4llmpymupdf4llm-mcppdf_oxidepdf_oxide-mcpnupdfnupdf-mcp
attachments–5/55/5–0/5–5/55/5
bookmarks–5/55/5–5/5–5/55/5
bundle5/55/55/55/55/55/55/55/5
claim5/55/55/55/55/55/55/55/5
clause5/55/55/55/55/55/55/55/5
columns5/55/55/52/55/55/55/55/5
comments–5/55/50/55/50/55/55/5
corpus5/55/55/50/55/55/55/55/5
damaged4/54/54/50/50/50/55/55/5
diff5/55/55/55/55/55/55/55/5
figure5/55/55/55/55/55/55/55/5
fill–5/55/5–0/5–5/55/5
formread5/55/54/50/55/50/55/55/5
hidden5/55/55/55/55/55/55/55/5
invoices5/55/55/50/55/55/55/55/5
links–5/55/50/54/55/55/55/5
locate5/55/55/50/55/55/55/55/5
longdoc5/55/55/50/55/55/55/55/5
merge–5/55/5–3/5–5/55/5
password–5/55/50/55/55/55/55/5
pii–5/55/5–0/5–5/55/5
quotes5/55/54/50/55/55/55/55/5
redact–5/55/5–0/5–5/55/5
redact2–3/55/5–1/5–5/55/5
rotate–5/55/5–5/5–5/55/5
sanitize–4/55/5–0/5–5/55/5
scans1/54/55/51/55/54/55/55/5
signature–5/55/50/50/50/55/55/5
sizes0/55/55/50/55/50/55/55/5
table5/55/55/55/55/55/55/55/5

Each arm against nupdf’s, per task, over the tasks both passed at least once: the geometric mean of the ratio of the medians (above 1x, nupdf spent less).

ArmAgainstTasks both passedTokensCallsWall time
nativenupdf-mcp161.61x0.86x1.13x
pythonnupdf-mcp301.28x0.94x1.70x
pymupdf4llmnupdf-mcp301.16x0.91x1.61x
pymupdf4llm-mcpnupdf-mcp91.21x1.07x1.89x
pdf_oxidenupdf-mcp232.14x1.29x1.83x
pdf_oxide-mcpnupdf-mcp161.56x1.61x1.63x
nativenupdf161.28x0.92x0.98x
pythonnupdf300.95x0.99x1.42x
pymupdf4llmnupdf300.86x0.97x1.35x
pymupdf4llm-mcpnupdf90.74x0.92x1.27x
pdf_oxidenupdf231.61x1.34x1.55x
pdf_oxide-mcpnupdf161.17x1.60x1.38x

What it shows:

  • Only nupdf solved everything: 150 of 150 runs on both arms. pymupdf4llm passed 147 and the python arm 145; PDF Oxide’s bindings 108, failing every run of attachments, fill, pii, redact, sanitize, signature and damaged, and its MCP server 79 of 105. pymupdf4llm’s MCP server passed 38 of 105: its one tool converts a file to Markdown without page boundaries, passwords, annotations or metadata, so agents could not say on which page anything was.
  • Over MCP nupdf spends the least: on the same tasks, 1.16x (pymupdf4llm) to 2.14x (PDF Oxide’s bindings) the tokens are the other arms’, and 1.13x to 1.89x the wall time.
  • The CLI does not: the pymupdf4llm and python arms spend 0.86x and 0.95x its tokens; it is still faster (1.35x and 1.42x).
  • Tool calls are even: 7 a run at the median for nupdf, python and pymupdf4llm alike; the native, python and pymupdf4llm arms make 6% to 14% fewer calls than nupdf over MCP on the same tasks. Only PDF Oxide’s arms make clearly more.
  • The invoices task no longer costs nupdf: extract --format layout keeps labels beside their values, and no nupdf run rendered a page to read it.

Limits: one model; three runs left a file in the shared runs/ folder, outside their own (two of them python-arm runs), which the grader ignores; a task’s tokens vary between its five runs by a median of 1.6x from least to most (2.7x at the 90th percentile), which per-task medians damp but do not remove.

Benchmarks

Speed of nupdf against MuPDF, the reference, and PDF Oxide (oxide.fyi, the pdf_oxide crates). How the runs work is in TESTING; earlier runs are at the end. The raw data for this page is in bench/results/2026-11-13-main-ca67177/ and bench/results/2026-11-14-calls/.

Against MuPDF and PDF Oxide, main at ca67177, 2026-11-13

Four comparisons, each answering its own question:

  • Per command against MuPDF: every nupdf command that has a MuPDF counterpart, against mutool, as processes, start included.
  • Engines: nupdf’s library against PDF Oxide’s, Rust to Rust, in one process on bytes already in memory. This is the comparison of the two engines.
  • Per call: nupdf’s CLI against mutool and PDF Oxide’s CLI, pdf-oxide, as processes, start included. This is what an agent with a shell pays for each command.
  • Python: PDF Oxide’s Python bindings, which the agent eval’s pdf_oxide arm used, as processes.
cargo xtask bench --tier standard --sample 200 --runs 3                     # against MuPDF
cargo xtask bench --tier long --runs 3
cargo xtask bench --tier standard --sample 200 --runs 5 --against engines   # builds bench/engines
cargo xtask bench --tier long --runs 5 --against engines
cargo install --locked --root target/oxide-cli pdf_oxide_cli@0.3.78        # the pdf-oxide CLI
cargo xtask bench --tier standard --sample 200 --runs 3 --against pdf_oxide
cargo xtask bench --tier long --runs 3 --against pdf_oxide
  • Machine: Apple M5 Max (18 cores: 6 “Super” and 12 “Performance”, as sysctl hw.perflevel* names them), 128 GB, macOS 27.2.1, on mains power, with other work on the machine (load average about 6 on 18 cores). Each command runs one process at a time, on one thread unless it says otherwise.
  • Versions: nupdf on main at ca67177, after the speed fixes. MuPDF 1.28.6, the pinned oracle. PDF Oxide 0.3.78 (MIT OR Apache-2.0) three ways: the pdf_oxide crate from crates.io with the features its CLI builds it with (rendering, logging, and its defaults, ICC through qcms and legacy crypto; not parallel); the CLI pdf-oxide (crate pdf_oxide_cli, built with cargo install --locked); and the Python bindings from PyPI on CPython 3.12.11 (oracle/bench/requirements.txt).
  • Builds: the engines crate, bench/engines, is outside the workspace (no product crate depends on pdf_oxide) and builds both libraries with the root workspace’s release profile (thin LTO, one codegen unit).
  • Files: standard is an evenly spaced 200 of the standard tier’s files, mostly small; long is the 30 documents of 96 to 362 pages merged from them (3,414 pages).

Ratios are MuPDF’s (or PDF Oxide’s) time over nupdf’s: above 1x, nupdf is faster. Each is a geometric mean over the files both tools did and, where checked, agree on; “totals” is the ratio of the summed times, which the largest files weigh most. Against PDF Oxide, a file both tools did is checked where their outputs must agree: the same page count, both with text or both without, the same number of pages with ink. A file on which they do not is counted as a disagreement and left out of the ratios, so that a fast empty answer is not a win.

Per command against MuPDF

Each pair runs the same task with mutool and with nupdf: one warm-up run, then the median of 3. Where MuPDF has no command, a script run by mutool run does the task (oracle/js/), its start included. nupdf’s writing commands read their output back to verify it; MuPDF’s do not.

CommandWhat it doesStandardTotalsLongTotals
infoopen, read the version, count pages and objects (nupdf also scans for scanned pages)1.57x1.48x0.26x0.15x
cleanrewrite with clean’s defaults1.38x1.41x1.02x0.82x
clean -ggggrewrite with every garbage collection level1.34x1.36x0.94x1.03x
clean -zrewrite, deflating every stream without a filter1.24x1.16x0.35x0.15x
mergemerge the document with itself: pages, links, outline1.37x1.38x0.85x0.81x
spliteach page to a file of its own (MuPDF: split.js)1.72x1.65x1.18x0.98x
tablesevery page’s tables, by MuPDF’s table hunting2.02x2.27x3.77x5.25x
annotsevery page’s annotations1.98x1.99x1.51x1.52x
formsevery page’s widgets1.99x2.00x1.45x1.42x
signaturesevery signature checked, digest included1.92x1.93x1.44x1.39x
flattenannotations and fields baked in, the document written1.89x1.91x1.43x1.17x
stextstructured text of every page as JSON1.77x2.25x3.44x6.91x
stext, 8 threadsthe same, nupdf on 8 threads, MuPDF on one1.77x2.35x6.71x17.43x
renderevery page at 72 dpi as PPM1.28x1.16x1.97x29.93x
render, 8 threadsthe same, nupdf on 8 threads, MuPDF on one (the oracle is built without threads)1.32x1.32x5.20x81.87x
  • Small files: nupdf is faster on every command, 1.24x (clean -z) to 2.02x (tables).
  • Long documents: faster on reading, extraction, tables, rendering and annotations, 1.4x to 3.8x in geometric mean on one thread and up to 6.7x on eight. Slower on two things:
    • info, 0.26x: nupdf’s scans every page’s text for pages that need OCR, which MuPDF’s does not. That takes it 5 to 168 ms a document (long-017.pdf the most), against 4 to 6 ms for MuPDF. Opening and counting pages alone, nupdf’s library is 5.85x faster than PDF Oxide’s (below).
    • Writing long documents: clean -z 0.35x (0.15x in total: 3.9 s against 0.59 s) and merge 0.85x; clean, clean -gggg and split are about even (0.94x to 1.18x, 0.82x to 1.03x in total). The same commands are 1.24x to 1.72x faster on small files, so the cost grows with the document (#86). flatten, which also writes, is 1.43x faster.
  • render totals on the long tier are one document’s: mutool draw ran into the bench’s 300 s timeout on long-018.pdf (nupdf: 0.52 s on one thread, 0.18 s on eight), and the pair counts the timeout as MuPDF’s time, so 300 of MuPDF’s 315 s are that file, and its real time is longer. Without it, render is 1.62x in geometric mean and 1.50x in total, render on 8 threads 4.26x and 3.81x.
  • Thread scaling (stext-scaling, extract-scaling: nupdf on one thread against eight, for documents of 50 pages or more that take 100 ms or more on one): on the long tier, where 6 documents qualify, stext is 3.13x faster on eight threads (3.15x in total) and extract 2.97x (2.97x); on the standard tier one document qualifies, at 3.21x and 3.08x.

Engines: nupdf’s library against PDF Oxide’s

Each run opens the document from the bytes in memory and does the operation on every page; the clock covers only that, not reading the file (nor the copy of the bytes PDF Oxide takes by value). The first run is a warm-up whose output is checked and not timed; the median of the next 5 is reported. Each file, operation and library runs in a child process of its own, so an error, a panic (caught with catch_unwind), an abort or a hang is that file’s failure only. Outputs are kept alive with std::hint::black_box.

OperationnupdfPDF Oxide
openDocument::open, page_countPdfDocument::from_bytes, page_count
pageseach page’s page and inherited MediaBox and Rotateget_page_media_box, get_page_rotation
textparallel::extract_pages on one thread (as nupdf extract), StextPage::text, into one Stringextract_text of every page (it looks for tables), into one String
text-fastas textextract_text_with_options with extract_tables: false
renderdraw::render_page_with through parallel::run_pages, RGB, ICC on (the CLI’s default)PageRenderer::render_page, RawRgba8 (premultiplied RGBA), ICC through qcms
tablesstructured text with table hunting and vectors (as nupdf tables), the tables countedextract_tables
annotsannot::page_annots, each one’s type, contents, rectangle and authorget_annotations

Both render at 72 dpi into a pixmap in memory, without encoding and without annotations (nupdf’s page contents alone, PDF Oxide’s render_annotations: false), with the same 100-megapixel limit. Annotations are counted without links, popups and widgets on both sides.

Standard tier, 200 files:

OperationPDF Oxide / nupdfTotalsnupdf total msPDF Oxide total msFailed: nupdf, PDF OxideDisagree
open2.21x1.03x9.29.51, 42
pages2.49x1.53x9.714.91, 61
text1.40x2.22x1894191, 46
text-fast1.24x1.68x1552601, 46
render1.58x3.20x1,0023,2041, 64
tables1.27x1.70x1953321, 4
annots2.41x1.44x9.613.91, 5

Long tier, 30 documents of 96 to 362 pages:

OperationPDF Oxide / nupdfTotalsnupdf total msPDF Oxide total msFailed: nupdf, PDF OxideDisagree
open5.85x7.35x2.619.40, 00
pages11.45x9.94x8.584.40, 70
text1.82x2.57x1,6234,1670, 00
text-fast1.55x2.01x1,6113,2340, 00
render3.08x7.36x2,58018,9921, 59
tables1.55x1.34x2,0472,7440, 0
annots22.03x8.10x33.42710, 0
  • nupdf’s library is faster on every operation and both tiers, in geometric mean and in total: 1.2x to 3.1x for text, rendering and tables, 2.2x to 22x for opening, page-tree walks and annotations.
  • The long tier’s largest single costs are now PDF Oxide’s: it renders long-008.pdf in 13.6 s (nupdf 0.76 s) and long-013.pdf in 5.8 s (nupdf 0.54 s). The documents that used to decide nupdf’s totals take it 0.47 s to render (long-018.pdf), 0.34 s for tables (long-012.pdf, where nupdf and MuPDF find 16 tables and PDF Oxide 1), and 26 ms for text (long-006.pdf).
  • Failures. nupdf fails crypt_filters.pdf (a crypt filter it does not support, as MuPDF does not), which PDF Oxide reads. It also fails to render one long document, long-014.pdf, whose 12,608 by 16,806 pt page is over the 100-megapixel limit at 72 dpi; PDF Oxide scales such a page down to fit its limit instead. PDF Oxide fails 4 to 6 standard files. Three are damaged files that nupdf and MuPDF repair (repeat_viewer_ref.pdf, bug1020226.pdf, issue-119.pdf). One is enc-XI-R6,V5,U=wwwww,O=wwwww.pdf, which needs a password: nupdf opens it and counts 0 pages, PDF Oxide returns an error, so in effect both refuse it. The rest are page boxes it cannot find and a few decoding errors. On the long tier it fails 7 page-tree walks (a page that inherits its MediaBox: get_page_media_box does not look up the tree) and 5 renders (unsupported or damaged streams).
  • Disagreements in rendering are pages one library leaves blank and the other does not, 1 to 8 pages a document.
  • Warm against cold. The warm-up absorbs one-time work. In PDF Oxide’s case it can be large: long-015.pdf’s text takes 2.2 s on the first call in a process and 59 ms after that. The per-call figures below include it; these do not.

Per call: nupdf, MuPDF and the pdf-oxide CLI

The CLI is what an agent with a shell would run. Where it has a command for the operation, this is the per-call comparison: wall time of the whole process, start included, median of 3 runs after one that must succeed. A side fails a file when it exits with an error or runs over 300 s; mutool exits 1 when any page has an error, even when it writes every page.

OperationnupdfMuPDFpdf-oxide CLI
infoinfo: version, page count, objects, permissions, metadata, and every page’s text scanned for pages that need OCRinfo.js: the same without the scaninfo --json: version, page count, metadata
textextract --format text to stdout, annotations’ text includeddraw -F txt to a filetext to stdout
render-pngrender (PNG), with annotationsdraw -N, PNG, with annotationsrender -o dir, RGBA PNG, with annotations
formsforms: each page’s widgetsforms.jsforms --json: the AcroForm’s fields
OperationTierMuPDF / nupdfCLI / nupdfCLI totalsFailed: MuPDF, nupdf, CLIDisagree
infostandard1.64x1.11x1.01x1, 1, 42
textstandard1.68x1.26x1.31x10, 1, 44
render-pngstandard1.28x2.16x1.93x10, 1, 78
formsstandard2.04x1.33x1.33x1, 1, 4
infolong0.24x0.24x0.16x0, 0, 00
textlong1.96x1.86x3.68x10, 0, 00
render-pnglong1.48x0.73x3.06x10, 0, 510
formslong1.48x0.91x0.65x0, 0, 0
  • On small files both CLIs are native processes that start in a few milliseconds, and nupdf is 1.1x to 2.2x faster per call.
  • Text on the long tier: nupdf is 1.86x faster in geometric mean and 3.68x in total (1.4 s against 5.2 s). The CLI’s text is the same as the library’s and the Python bindings’ to the character.
  • info on the long tier is nupdf’s loss: nupdf info scans every page’s text for pages that need OCR (up to 0.16 s on long-017.pdf, against about 3 ms for the CLI and 4 ms for MuPDF). Opening and counting pages alone, nupdf’s library is 5.85x faster than PDF Oxide’s (above).
  • render-png on the long tier: the CLI is faster on most documents (0.73x in geometric mean) and far slower on a few (3.06x in total: 13.7 s against nupdf’s 0.82 s on long-008.pdf). nupdf’s library draws faster than PDF Oxide’s (3.08x above), and nupdf is 1.48x faster than MuPDF, which writes PNGs of the same size; the difference with the CLI is encoding PNG. On long-020.pdf nupdf draws 100 pages in 6 ms and takes 171 ms with PNG; the CLI takes 64 ms with PNG. PDF Oxide’s PNGs are RGBA and about twice the size (1.7 MB against 0.8 MB for that document, where nupdf and MuPDF write the same size), so it compresses less. Speeding up nupdf’s PNG encoder is #81.
  • forms on the long tier: nupdf walks every page’s widgets where the CLI reads the AcroForm; these documents have no fields, and both take about 3 ms in the median.

PDF Oxide in Python, as the agent eval used it

The same operations through the Python bindings, as processes: the interpreter’s start and import pdf_oxide take 32 to 35 ms a call (median per operation; 142 ms for the long tier’s raw render), which is most of the time on small files. Python’s time over nupdf’s, per call:

Operationstandardlong
info12.7x1.96x
pages15.6x13.5x
text12.9x4.31x
text-fast12.9x3.94x
render (raw pixmap, written as PAM)11.4x5.82x
render-png7.79x0.90x
tables12.9x3.59x
annots15.7x10.2x
forms15.6x8.76x
signatures15.7x8.82x

The bench also times Python in process, against nupdf’s process time less its start. That comparison is not the engines’: nupdf’s side still includes reading the file, hashing it for doc_id and writing a report, so it is kept in the raw data only, and the engines comparison above replaces it.

What the outputs show

  • Failures. The CLI and Python fail mostly the same files as the library, through the same parser, and the CLI’s render stops at the first page that fails.
  • Disagreements in the CLI and Python runs are mostly about what each tool draws or counts. Text: on 1 file (bug_1206.pdf) PDF Oxide has text where nupdf has none; on 3 nupdf has text and PDF Oxide none (bad24.pdf, a .notdef test, and a FreeText annotation’s text). Render: on 4 standard files the only ink is an annotation, which nupdf draws and PDF Oxide does not; the other 4 differ in the page contents, as in the engines run. On the long tier the counts of pages with ink differ by 1 to 8 a document. Info and pages: PDF Oxide counts 1 or 2 pages in two files whose page tree has none (MuPDF and nupdf count 0).
  • Counts that are not checked differ by design: PDF Oxide’s tables are found its own way (9 against nupdf’s 40 on the standard tier), and its form fields are the AcroForm’s terminal fields rather than each page’s widgets. With links, popups and widgets left out on both sides the annotation counts are close (40 against 39).
  • Correctness seen in passing. PDF Oxide’s page boxes do not inherit: on corpus/smoke/stext.pdf, whose pages inherit a 300 by 300 MediaBox from the page tree, Python’s page_media_box returns US Letter, and the library’s get_page_media_box fails outright on 7 long documents. On long-005.pdf its text has 61% of nupdf’s characters, which the both-have-text check does not catch.

Caveats

  • info is not like for like in the per-command and per-call comparisons (nupdf’s scans every page’s text); the engines’ open is.
  • In the render pairs against MuPDF, mutool draw -N has colour management off and nupdf render has it on, its default.
  • A MuPDF pair counts a run over its 300 s timeout as 300 s (long-018.pdf above); the per-call comparison counts it as a failure.
  • In the engines comparison, nupdf renders with ICC on, its CLI’s default; PDF Oxide’s ICC is qcms, its default. nupdf has no speed-first text mode, so its text-fast row is its text run again.
  • In the per-call comparison PDF Oxide’s PNGs are RGBA. The CLI has no tables, annotations, signatures or page-box commands, and no speed-first text option.
  • The machine was not idle: the standard tier’s engine totals are about twice the earlier run’s (below) on both sides, for the same work, so compare ratios, not milliseconds, across runs. One-off spikes move single files; medians and geometric means damp them but do not remove them.

One call, and a long document: the text, eleven ways, 2026-11-14

What an agent pays to get a file’s text, against every tool it might use instead. Each tool is asked for the text of every page on stdout, as a process of its own: start-up and imports are in the time. The raw data is in bench/results/2026-11-14-calls/; the website’s two speed tables are these.

cargo install --locked --root target/oxide-cli pdf_oxide_cli@0.3.78
python3 bench/calls/run.py --set typical --runs 5 --corpus ~/.cache/nupdf/corpus --nupdf target/release/nupdf --mutool MUTOOL --oxide target/oxide-cli/bin/pdf-oxide --python VENV/bin/python --out OUT/typical
python3 bench/calls/run.py --set long --runs 5 --limit 300 ...   --out OUT/long
python3 bench/calls/summary.py OUT/typical --mutool MUTOOL
  • Machine: Apple M5 Max, macOS 27.2.1, with a load average of 2 to 3 for most of the run (about 12 as the typical set began). One Apple silicon Mac is the only machine this has run on.
  • Versions: nupdf 0.1.0 (main at 7a964d8), MuPDF 1.28.6, Poppler 26.04.0, PDF Oxide 0.3.78 (its CLI and its Python bindings), and on Python 3.12.11: PyMuPDF 2.1, pypdfium2 5.15.2 (PDFium 8149), pypdf 7.0.1, pdfplumber 0.11.10, pdfminer.six 20260107.
  • Files: typical is an evenly spaced 200 of the standard tier’s 5,142 files; long is the 30 long documents.
  • Calls: nupdf extract --format text (and with --threads 8), mutool draw -q -F txt, pdftotext FILE -, pdf-oxide text, and for each Python library a one-line script run with python -c. Each call runs five times; a call’s time is the median. mutool exits 1 when a page had an error and still writes the rest, which counts as done.
  • Speed and text are judged apart. Times are over the files every tool completed (187 of 200, and 17 of 30 with 1,777 pages), so no tool is timed on a set of its own. A file’s text is gradable when MuPDF’s and PDFium’s words for it agree (token F1 of 0.9 or more, through their Python bindings), and a call’s text is right when its words match PDFium’s (F1 of 0.8 or more). PDFium is the judge because nupdf follows MuPDF.

One call (typical):

ToolMedian callFastest of 5, medianProcessor time, medianPeak memory, medianDoneRight text
nupdf10.8 ms6.2 ms0 ms6.5 MB199 / 200162 / 162
PDF Oxide, pdf-oxide CLI11.9 ms10.7 ms0 ms10.8 MB196 / 200157 / 162
MuPDF, mutool11.9 ms10.7 ms0 ms5.6 MB191 / 200161 / 162
Poppler, pdftotext23.4 ms21.1 ms0 ms10.4 MB194 / 200160 / 162
PDF Oxide, Python bindings46.3 ms42.4 ms20 ms30.3 MB196 / 200157 / 162
pypdf83.8 ms78.6 ms50 ms37.7 MB190 / 200156 / 162
PDFium, through pypdfium284.2 ms79.1 ms40 ms40.5 MB194 / 200162 / 162
pdfminer.six84.6 ms78.8 ms50 ms35.6 MB192 / 200151 / 162
MuPDF, through PyMuPDF84.7 ms79.3 ms50 ms55.6 MB197 / 200162 / 162
pdfplumber84.8 ms78.9 ms50 ms36.3 MB192 / 200155 / 162

A long document (long):

ToolPages a secondTotalProcessor time, totalPeak memory, medianPeak memory, mostDoneRight text
nupdf, 8 threads2,7290.65 s0.95 s32.0 MB203 MB30 / 3014 / 14
nupdf1,5681.13 s0.63 s13.0 MB158 MB30 / 3014 / 14
Poppler, pdftotext1,0721.66 s1.16 s13.5 MB76 MB30 / 3013 / 14
PDFium, through pypdfium27572.35 s1.68 s52.1 MB237 MB30 / 3014 / 14
MuPDF, mutool4983.57 s2.97 s10.0 MB83 MB30 / 3014 / 14
PDF Oxide, pdf-oxide CLI3425.20 s4.70 s29.1 MB230 MB30 / 3014 / 14
PDF Oxide, Python bindings3185.60 s4.82 s47.5 MB255 MB30 / 3014 / 14
MuPDF, through PyMuPDF3055.82 s5.11 s59.3 MB109 MB30 / 3014 / 14
pypdf1889.43 s8.78 s44.7 MB195 MB21 / 3010 / 14
pdfminer.six10516.94 s16.22 s42.6 MB142 MB23 / 3011 / 14
pdfplumber6925.68 s24.98 s84.1 MB622 MB20 / 303 / 14
  • One call: the three native tools are within about 10% of each other, which is within what a rerun moves them by. Anything started from Python takes about 85 ms, most of it the interpreter and the import.
  • Long documents: nupdf leads on one thread and on eight. It is not quicker on every document: pdftotext was quicker on 8 of the 30, pdf-oxide on 4 and mutool on 2.
  • Memory: mutool uses the least in both sets.
  • PDF Oxide’s two rows differ by start-up alone: about 12 ms a call from its CLI against 46 ms from Python, and nearly the same pages a second on long documents.

Caveats

  • Wall times moved between reruns on this machine: an earlier pass gave nupdf 4.9 ms and mutool 6.0 ms a call on the same files. The order and the ratios held; the milliseconds are approximate.
  • Three Python libraries fail 7 to 10 of the long documents. Some of pypdf’s failures are a missing optional dependency here (Brotli), not pypdf’s own limit.
  • “Right text” compares words, not order or layout.
  • The harness must not hold the tools’ output: an earlier version did, grew, and every later spawn then cost 180 to 280 ms. run.py sends each tool’s output straight to a file.

Earlier runs

Each with the code of its date. Ratios are MuPDF’s or PDF Oxide’s time over nupdf’s, as above.

  • Against PDF Oxide, 2026-11-13, at 1abb7e5, before the speed fixes (bench/results/2026-11-13-pdf_oxide/, the same commands, load average 7 to 18). In geometric mean nupdf’s library was already faster on every operation (engines: 1.10x to 15.4x), but on the long tier it lost the totals of text (0.73x), rendering (0.19x) and tables (0.18x) to three documents: it rendered long-018.pdf in 77 s (PDF Oxide 1.7 s), hunted tables in long-012.pdf for 11.3 s (PDF Oxide 0.22 s) and spent about 2.5 s on one page of long-006.pdf. Per call, info on the long tier was 0.17x MuPDF’s (up to 2.4 s a document for its scan) and render-png 0.84x MuPDF’s and 0.29x the CLI’s.
  • clean, merge, split, 2026-11-10, on 300 standard-tier files: nupdf clean 614 ms in total against mutool clean’s 900 ms (geometric mean 1.50x), its read-back check included; clean -gggg 724 ms against 1,035 ms (1.48x); clean -z 916 ms against 1,050 ms (1.36x), where the check decodes every stream. nupdf merge of each file with itself 677 ms against mutool merge’s 924 ms (1.44x), links and outline included; nupdf split 935 ms against 1,518 ms for a MuPDF script that does the same (oracle/js/split.js; 1.94x, the script’s overhead included). clean is slower on files of a megabyte or more, where hashing the input and the output for their doc_id costs more than MuPDF’s whole save; merge and split on 5 to 11 files, the larger ones, for the same reason.
  • render, 2026-11-11, on 200 standard-tier files at 72 dpi against mutool draw -N: one thread, geometric mean 1.25x (1,293 ms in total against 1,357 ms); eight threads, 1.28x (the oracle is built without threads, so MuPDF runs on one).
  • Thread scaling, 2026-11-12 (stext-scaling, extract-scaling), on 6 performance and 12 efficiency cores with Finder, fseventsd and Spotlight each holding a core: on the standard tier stext 3.4x (one document qualifies), extract 2.8x (two); on the long tier (9 qualify) stext 2.36x in geometric mean and 1.29x in total time, extract 2.33x and 1.25x. The total was one document’s: a single page of long-006.pdf took 2.3 s (MuPDF 2.0 s), which threads over pages cannot split. That page has since dropped to 0.02 s, and the run above has the scaling after the fix.

The wild tier

How nupdf fares on PDFs from the open web: 100,000 files from SafeDocs’ CC-MAIN-2021-31-PDF-UNTRUNCATED crawl (zip files 0000 to 0099), unsanitized, malware included. They are not committed; corpus/manifest.toml says where they come from.

Measured on 2026-11-12 at commit de4b887, MuPDF 1.28.6 as the reference, on an M-series Mac.

Crashes

cargo xtask crashes --tier wild runs fifteen nupdf commands on each file, each in its own process with a 60-second limit: info, objects, extract, search, render (pages 1 to 3 at 36 dpi), fonts, images, tables, annots, forms, signatures, outline, clean, flatten and sanitize. That is 1,500,000 runs.

First sweepAfter the fixes
Signals (segfaults, aborts, stack overflows)00
Panics620
Over 60 seconds6925
  • The panics were one bug: a stroke under an empty clip, where Rust’s f32::clamp panics on a minimum above its maximum and MuPDF’s fz_clamp gives the minimum.

  • About half the timeouts were the machine: the sweep ran fourteen processes at once alongside other work, and those files take seconds on their own.

  • The rest were two slow paths, both fixed:

    • Shadings hashed a whole embedded ICC profile for every vertex colour.
    • Pages that set an ICC colour space every few operators inflated its profile each time.

    MuPDF keeps both in its store; nupdf now does too.

  • Five runs still took over 60 seconds after those fixes, slower than MuPDF, on files of 4 to 43 MB. Profiled and fixed since, with the same output (user CPU seconds, before and after; MuPDF):

    • images and fonts on a file of 7,560 pages sharing one /Resources of 7,560 forms: 64 s and 66 s to 0.04 s (MuPDF’s images 54 s).
    • tables: 125 s to 3.3 s (MuPDF 126 s) and 105 s to 52 s (MuPDF 83 s).
    • sanitize: 46 s to 24 s (mutool clean 9 s). It saves, reparses, scans and verifies its output, so it does more than clean; that is what is left.

Parity

cargo xtask harness --tier wild --sample 1000 on an evenly spaced sample of 1,000 files. “Both failed” means MuPDF and nupdf both refuse the file; parity counts matches among the files either side reads.

CheckMatchMismatchOracle failedBoth failedParity
info99440299.6%
objects998002100%
pages998002100%
streams998002100%
images998002100%
operators998002100%
stext99125299.8%
text99341299.6%
search998002100%
fonts998002100%
outline998002100%
links998002100%
annots998002100%
forms998002100%
signatures99710299.9%
tables99710299.9%
clean (BLAKE3 of the written file)98870599.3%
flatten (BLAKE3 of the written file)98870599.3%
render-72 (each page’s pixels within the threshold)954 files44 files0295.6% of files; 12,869 of 13,041 pages (98.7%)

Rendering was the one that fell short: 94.0% of the pages at first (with colour management off on both sides). Triaging the 785 pages that differed page by page against MuPDF (cargo xtask triage FILE --page N, and MuPDF’s own trace of each page) found eight causes, six of them now fixed in the renderer; the table above is after them (the render-72 row was measured on 2026-11-13; the rest are from 2026-11-12). The standard tier went from 99.3% to 99.7% of pages with the same fixes.

CausePagesFiles
JPEGs shown small: MuPDF decodes them up to 8 times smaller in libjpeg’s reduced IDCTs, nupdf decoded them whole and averaged395131fixed
Transparency groups with a /CS (mostly CMYK page groups of print layouts): MuPDF blends in that colorspace without ICC, nupdf blended in RGB13533fixed
Text drawn with a stroke (render modes 1 and 2, often a thin stroke for fake bold): MuPDF strokes glyphs with FreeType’s stroker, nupdf strokes their outlines as paths9729left
Non-embedded CJK fonts: MuPDF substitutes its bundled Source Han Serif, which nupdf does not carry (DECISIONS D32, D34), so their text does not draw7012left
Images mostly out of view: MuPDF decodes only the part that shows (under 90% of the image) and scales that6536fixed
CFF fonts made oblique by their FontMatrix: FreeType applies the skew, nupdf took only the em94fixed
Image masks in Type 3 glyphs (TeX bitmap fonts): MuPDF does not fit them to the pixel grid72fixed
Lab images and colours: MuPDF reads Lab samples as L 0..100 and a, b offset by 128 and converts by its own formula22fixed
Not triaged: Type 3 bitmap glyphs on one page, dashed table rules drawn as images on three, an image under a mask on one53left

A page is counted under the fix that made it match; files can have pages in more than one row. What is left is 172 pages in 44 files: the stroked text, the CJK fonts and the five untriaged pages. Stroked text needs a port of FreeType’s stroker (ftstroke.c, about 2,400 lines) to draw glyph strokes as MuPDF does, which is a work item of its own; the CJK fonts wait on the decision to carry MuPDF’s 25 MB font.

clean and flatten compare written files byte for byte, so AES-encrypted files always differ there: AES writes a random initialization vector.

Reproducing

cargo xtask crashes --tier wild --jobs 8
cargo xtask crashes --tier wild --summary
cargo xtask harness --tier wild --sample 1000 --check info

The sweep logs every run to target/crashes/wild.jsonl and resumes with --resume.

Agent interface

The contract that agents program against. The CLI and the MCP server expose the same commands with the same JSON. Library functions return the typed reports these documents describe. Schemas are versioned and frozen at v1: each report’s JSON Schema is nupdf schema <command>, and docs/SCHEMA-CHANGELOG.md says what changes and when. A breaking change (a field removed, renamed or given another type, a value given another meaning) bumps the version in schema; adding a field does not.

Rules every command follows

  1. JSON on stdout. One JSON object per invocation (--format jsonl for per-page streams). --pretty indents it. Human formats are opt-in: --format text, markdown or layout where they make sense.
  2. Self-describing. Every report has schema ("nupdf.<command>/v<N>") and the input’s doc_id.
  3. Diagnostics are data. Every report has a diagnostics array of {code, severity, message, page?, object?, offset?}. A degraded result says so here instead of failing silently.
  4. Errors are JSON on stderr. {"schema": "nupdf.error/v1", "error": {"code", "message", "hint"?}}, and nothing on stdout.
  5. Exit codes mean one thing each. See the table below.
  6. Inputs are never modified. Mutating commands need -o <path> (or an explicit --in-place), write atomically, and report the output’s doc_id, size and verification block.
  7. Every mutation has --dry-run. It returns the plan, writes nothing, and uses the same schema with "applied": false.
  8. Budgets are explicit. --max-chars, --max-items, --fields <list>, and pagination through next_cursor. Large outputs are truncated deliberately, with "truncated": true and a cursor, never cut off mid-object.
  9. Limits are explicit. --timeout, --max-memory and the decompression limits have safe defaults. Hitting one is an error with a limit.* code, not a hang. A command out of time (--timeout SECONDS) writes nothing, not its report and not its output files, and fails with exit 5 and limit.timeout.
  10. Deterministic. Same file, same arguments, same nupdf version: byte-identical output.
  11. Discoverable. nupdf guide prints compact usage written for models. nupdf schema <command> prints the JSON Schema of a report.

Exit codes

CodeMeaning
0Success. Warnings, if any, are in diagnostics.
1Internal error: a bug in nupdf. Please report it.
2Usage error: bad arguments.
3Input error: unreadable, not a PDF, wrong password, damaged beyond repair.
4Verification failed: a mutation’s self-check found a problem, such as redacted text still present.
5Limit exceeded: time, memory, decompression or size.

Encrypted documents

Every command takes --password, tried as both the user and the owner password. Without it, nupdf tries the empty password, as MuPDF does, and opens the file either way: info reports "needs_password": true and leaves page_count null when the empty password does not work, and objects then decrypt to the same unreadable bytes MuPDF shows. A password that opens neither way fails with exit 3 and input.wrong_password. A command that writes refuses a document the empty password does not open, unless --password is given, with exit 3 and input.needs_password, as mutool clean refuses it. info.permissions says what the document allows (print, edit, copy, annotate, form, accessibility, assemble, print-hq): everything, unless it is encrypted and was not opened with the owner password. info.encryption is MuPDF’s description, such as "Standard V5 R6 256-bit AES", or "None". A security handler nupdf does not support (anything but /Standard) fails to open with input.encryption.

Writing documents

clean <file> -o <out> rewrites the whole document as mutool clean does: every object in number order in a classic xref table, object streams and xref streams unpacked, streams copied raw, the encryption and /ID kept. The bytes are MuPDF’s, except for the comment line naming the writer and the AES initialization vectors (DECISIONS D18). --dry-run does everything but write; -o or --dry-run is required.

-g collects garbage as mutool clean -g does, and repeating it goes further (DECISIONS D19). -g writes only what the trailer reaches and turns references to nothing into null. -gg also renumbers the objects from 1, -ggg also merges duplicate objects, and -gggg also merges streams with the same data. The report’s garbage gives the level, 0 to 4.

The stream options are mutool clean’s (DECISIONS D20). -d writes streams decoded, apart from XML metadata and JPEG 2000 images. -z compresses streams that have no filter: with zlib’s deflate, or for a 1-bit image with CCITT G4, after taking off any ASCIIHexDecode. -f and -i do the same for fonts and images only, and keep them from being decoded by -d. -a hex-encodes binary streams, and writes strings outside printable ASCII in hex. -e N sets the compression effort: 0 for zlib’s default level, or 1 to 100 for levels 0 to 9. -zz (Brotli) is refused with exit 2 and usage.unsupported. Objects are written compact, or with -a or -d and no -z, a dictionary entry per line; -t forces compact and -tt a line per entry. The report’s streams gives the options and syntax gives compact or pretty.

-Z packs objects into object streams and writes an xref stream instead of the table, as mutool clean -Z does, raising the version to 1.5 (DECISIONS D21). As in MuPDF, it fails with input.unloadable_object on a document with an object that does not load or, in most files, an object number the xref skips, even where clean without -Z writes it. The report’s objstms says whether it was given.

--pages 1-3,5 keeps only those pages, in that order, as mutool clean’s page arguments do: N is the last page, a range can run backwards (5-1), and a page can repeat (DECISIONS D23). The catalog is rebuilt with only the page tree, outlines, optional content and the named destinations that still lead somewhere, and links, form fields and outline items that lead to dropped pages are removed. The report’s pages lists the pages kept, from 1. A page tree that cannot be walked fails with input.page_tree.

-c rewrites the content streams as mutool clean -c does: each page’s contents become one new stream, written back operator by operator (numbers at their shortest round-trip digits), and the forms, patterns, soft-mask groups and Type 3 glyphs its resources use are rewritten in place. -s sanitizes them as mutool clean -s does: state is sent only once something is drawn, redundant state goes, q/Q balance, and each page and form gets resources cut to what its contents use (DECISIONS D39, D40). Annotation appearances are rewritten too. MuPDF also gives annotations without appearances new ones when it loads their page; nupdf does not.

-D writes the document without encryption. -E rc4-40|rc4-128|aes-128|aes-256 encrypts it with a new security handler, with -O and -U for the owner and user passwords (empty by default; with no user password the output opens without one) and -P for the permission flags (-1, everything, by default), as mutool clean does (DECISIONS D22). As in MuPDF, -E gives the document a new /ID; nupdf derives it, and every other value MuPDF draws at random, from the input, so the output is reproducible. -D and -E cannot be combined. The report’s encryption is "keep", "none", or the new method and permissions. The read-back check opens the output with the new user password. It refuses output where MuPDF’s encryption loses a stream: an empty stream under AES, or a stream with a crypt filter of its own.

Before writing, nupdf opens the output (with --password, if given) and compares it with the input: the syntax of each object, apart from the stream /Length it recomputes, and each stream’s raw data. With a stream option, streams are compared decoded instead, and their /Filter and /DecodeParms are left out. With -Z, objects in object streams are compared too, the object streams and xref stream the writer adds are not, and the catalog’s /Version is left out. Without -g, every object the output has in use is compared with the input’s of the same number. With -g, objects are paired by following references from the trailer, since they may be renumbered or merged, and objects counts the pairs. If the output does not open, needs a repair, or reads back differently, clean writes nothing and fails with exit 4 and verify.failed, naming the input’s objects. MuPDF’s writer is lossy in a few cases, such as an empty key name before a number, a stream under an Identity crypt filter, a reference renumbered in a dictionary of more than 100 entries, with -gg a document whose trailer has no /Root, or, with -z or -a, a filter array two streams share. --no-verify skips the check and writes MuPDF’s output anyway.

{"schema":"nupdf.clean/v1","doc_id":"b3:...","applied":true,"garbage":0,
 "streams":{"decompress":false,"compress":false,"compress_fonts":false,
  "compress_images":false,"ascii":false,"effort":0},"syntax":"compact","objstms":false,"pages":null,"encryption":"keep",
 "output":{"path":"out.pdf","doc_id":"b3:...","len":1574,"blake3":"..."},
 "verification":{"verified":true,"objects":7},"diagnostics":[]}

verification is null with --no-verify. An object that cannot be loaded while writing fails with input.unloadable_object; an output path that cannot be written fails with exit 3 and output.unwritable. The output is written to a temporary file beside it and renamed into place.

Editing documents

Each editing command makes one edit, then writes the document as clean writes it with no options: -o or --dry-run is required, --no-verify skips the read-back check, and the report carries applied, output and verification as clean’s does (DECISIONS D24). With --incremental, meta and pages append the edit to the file as a new revision instead, leaving its bytes as they were, as MuPDF’s incremental save does (DECISIONS D27); a repaired file, and anything merge makes, cannot be appended to, which fails with exit 2 and usage.incremental.

meta <file> reports the document information dictionary as text, and xmp, whether the catalog has XMP /Metadata. --set KEY=VALUE (repeatable) sets an entry as MuPDF’s pdf_set_metadata does: info:Title and Title are the same key, and the value is written as a text string. --strip removes /Info and the catalog’s /Metadata first. The report shows the entries as written and stripped.

{"schema":"nupdf.meta/v1","doc_id":"b3:...","applied":true,"info":{"Title":"Été"},"xmp":false,"stripped":false,
 "output":{...},"verification":{"verified":true,"objects":7},"diagnostics":[]}

pages <file> lists the pages; with an edit it writes them changed. --select 3,1-2 keeps those pages in that order and --delete 2 drops those, both as clean --pages; --rotate 90 turns pages clockwise by a multiple of 90, all of them or those --rotate-pages names, counted after the selection. The report’s kept lists the pages kept, from 1, rotated the degrees and page_count the count after.

split <file> -o <dir> writes one file per page, or per --pages-per-file N pages, or per page list in --ranges "1-3;4-N", each as clean --pages would. The parts are <stem>-1.pdf onwards, zero-padded to the same width. The report’s parts give each part’s pages, output and verification; --dry-run writes nothing, not even the directory.

merge <inputs>... -o <out> copies pages into a new document, as mutool merge does (DECISIONS D25). Each input file may be followed by its pages, in the same syntax (a.pdf b.pdf 3-1 c.pdf N); without them, all of its pages are copied. Objects two pages of one input share are copied once. The report’s doc_id is the first input’s that opened, and inputs gives each input’s path, doc_id, the pages merged from 1, error, and the diagnostics from reading it. An input that fails partway, such as one whose page refers past its xref, keeps the pages merged before it, as in MuPDF; its error says why, and a merge.input_failed warning is in the report’s diagnostics. A locked input fails that way without --password. Only when no input opens does the command fail. A page’s links to other files and the web come with it, recreated as new Link annotations; links within the document do not, as in MuPDF. Outline items that lead to a merged page are appended to the outline, with their ancestors, renumbered to the merged pages (DECISIONS D26).

{"schema":"nupdf.merge/v1","doc_id":"b3:...","applied":true,
 "inputs":[{"path":"a.pdf","doc_id":"b3:...","pages":[1,2],"error":null,"diagnostics":[]}],
 "page_count":2,"output":{...},"verification":{"verified":true,"objects":9},"diagnostics":[]}

encrypt <file> is clean -E: --method (aes-256 by default), --owner-password, --user-password and --permissions. decrypt <file> is clean -D. Their reports (nupdf.encrypt/v1, nupdf.decrypt/v1) carry encryption as clean’s does.

Addressing content

  • Pages are 1-based. Selections use MuPDF’s syntax: 1-3,7, N for the last page, 5-N, N-1 for reverse order.
  • Coordinates are in points, in MuPDF’s page space: origin at the top left, y pointing down, page rotation applied. This matches what render draws, so a bbox read from extract can be overlaid on a rendered image without conversion.
  • Content ids are stable for a given doc_id and schema version: p3 (page), p3.b2 (block), p3.b2.l4 (line), p3.b2.l4.s1 (span), and p3.b2.l4.s1:5-12 (a character range within a span). Search hits refer to these ids, and redaction accepts them. An id used with a different doc_id is rejected, so agents find out the file changed.
  • Regions are p3:[x0,y0,x1,y1] in the coordinate space above.

Commands

CommandWhat it does
infoTriage: version, pages, encryption, repairs, revisions, text-layer presence per page, scanned-page detection, fonts at risk (no ToUnicode), forms, JavaScript, attachments, signatures, plus suggested next commands
objects, show <ref>Canonical JSON dump of objects
pagesPage objects with inherited MediaBox, CropBox, Rotate and Resources
streamsEvery stream’s decoded length and BLAKE3 hash (image filters listed, not decoded; --images decodes only those); show <ref> --data decodes one
decrypt, encryptRemove or add encryption
merge, split, pages, meta, cleanStructural editing
operatorsEach page’s content-stream operators with their operands, as MuPDF’s interpreter dispatches them; --no-resources processes the content streams alone
traceEach page’s device calls, as MuPDF’s run processor makes them for the page’s contents: paths with their matrices, colors and stroke states, clips, images, shadings, text (spans with their font, matrix, writing mode and language, and each glyph’s id, Unicode and position), groups, masks, layers, structure and metatext
fontsThe fonts the pages use, as mutool info finds them (page, object, subtype, name, encoding), with what each loads as (load: program, glyph count, embedded or substituted, ToUnicode, vertical); --extract DIR writes each embedded program as mutool extract does (font-NNNN.ext)
imagesThe images the pages use, as mutool info finds them (page, object, size, depth, filters, colorspace); --digests decodes every image XObject as MuPDF does and gives its pixmap’s size, components, colorspace and MD5; --extract DIR writes each as mutool extract does (image-NNNN.jpg, .png or .pam; --format pam for all PAM)
stextEach page’s structured text as MuPDF’s stext device gathers it from the page’s contents: blocks (text; images with preserve-images; with vectors, the boxes of filled and stroked paths as {"type": "vector", "bbox", "flags"}, flags stroked 1, rectangle 2, continues 4; with segment, the page split at its widest gaps into nested {"type": "struct", "standard": "Div", "raw": "Split", "index", "blocks"} blocks; with paragraph-break, text blocks broken into paragraphs, titles as H and list items as LI structures, and justified blocks flagged 4; with table-hunt, tables as Table structures of TR rows of TD cells; with reading-order (nupdf’s own, D38), blocks reordered by a recursive XY-cut; search, as MuPDF’s, looks only at the blocks outside structures), lines with their box, writing mode and direction, and characters as [text, origin, font, size, quad, flags, bidi] (quads as [ul, ur, ll, lr], flags as MuPDF’s FZ_STEXT_* character flags); --options takes MuPDF’s stext option string (preserve-ligatures, dehyphenate, clip=no, clip-rect=x0:y0:x1:y1, …); an unknown option fails with exit 2 and usage.bad_option. stext, extract and search take --threads N: pages run on N threads with output identical at every N (D37)
extractThe pages’ text with content ids: --format json (pages, blocks, lines and spans, with boxes, fonts, sizes, bold, italic and colors; --fields picks levels and keys, text going at the deepest level kept, chars adding each character’s quad), jsonl (a nupdf.extract.page/v1 object per page, then the report), text (as mutool draw -F txt writes it: MuPDF’s fz_print_stext_page_as_text of the whole page as fz_run_page runs it into a display list and the list plays it (D52), so the text annotations and form fields show comes after the contents’; a form feed after each page; --no-annots for the contents alone) markdown (headings, list items, bold and italic, a <!-- p3 --> comment per page) or layout (each row of the page on one line, lines at the columns their positions give, so a label and its value share a line; a form feed after each page; DECISIONS D51); for text, Markdown and layout the report goes to stderr, and layout’s budget stops at a whole page. The other formats have the contents’ text alone, as Page.toStructuredText gathers it, so that their content ids are the ones search and redact use. --pages selects pages, --options takes the stext options, --max-chars and --max-items stop at a whole block with "truncated": true and a next_cursor (p3.b5@<doc_id>) that --cursor resumes from; a cursor for another document fails with exit 2
searchHits of a needle as MuPDF’s search finds them (fz_match_stext_page_cb): --options takes MuPDF’s search options (ignore-case, ignore-diacritics, regexp, keep-lines, keep-paragraphs, keep-hyphens), --stext-options the stext options. Each hit has its page, its quads, its text and the content ids of its characters: id (p3.b2.l4.s1:5-12) when it lies in one span, otherwise begin and end (p3.b2.l4.s1:5). A pattern that does not compile is a page error, as in MuPDF. --pages and --max-items as for extract
redact, verifyRemove content and prove it is gone: see the redaction contract below
renderPages drawn as mutool draw draws them (fz_run_page: the page contents, then the annotations, then the form fields, each appearance stretched onto its /Rect; hidden, NoView and switched-off optional content left out; an annotation without an appearance drawn with the one MuPDF makes for it, kept out of the document; --no-annots draws the contents alone, as page.runPageContents; DECISIONS D52) into page-NNNN.png (or --format ppm) in -o DIR: --pages, --dpi (72 by default), --region x0,y0,x1,y1 (points from the page’s top left), --max-pixels (a larger page is an error for that page, not the command), --threads N (pages on N threads, and a large page in bands, with the same pixels at every count). Color management is on, as in mutool draw (MuPDF’s ICC profiles, with moxcms where MuPDF has lcms2); --no-icc converts by MuPDF’s formulas, as -N. Each page reports its size, its offset, the file it went to, and run_error when the page’s run broke off (an error in the contents ends it there, as in MuPDF)
mcpThe commands as tools of an MCP server over stdio: each tool is a command, its arguments the command’s options (file, max_chars, …), plus password, the limits and timeout; the result is the command’s report as text and as structuredContent, a failure a tool error with nupdf’s error report. --call-timeout (300 s) bounds every call, --max-output-bytes (8 MB) the report; a cancelled call stops and gets no answer (D42)
batchA command on many files: nupdf batch <files, dirs, globs>... -- <command> [args], --jobs at once, a nupdf.batch.item/v1 line per file in the order given (file, size, modification time, exit, the command’s report or error), then the nupdf.batch/v1 summary; -o FILE writes the lines there and --resume skips the files it already has unchanged; {stem}, {name}, {index} in the arguments name each file’s outputs; --item-timeout per file (D43)
diffTwo PDFs compared, --by text,structure and on request pixels: text as pages deleted, inserted or changed (pages lined up by their text) and, for each changed page, lines deleted and inserted with their content ids in each document; structure as named parts that differ (page_count, page.size.3, page.rotate.3, info.Title, outline, fonts, …); pixels as each page’s differing pixels, their share and bounding box in points at --dpi; identical over what was compared (D44)
guide, schemaguide: how to use nupdf, as text for a model, every command listed with its options (guide <command> for one in full, guide --skill for the Claude Code skill); schema <name>: a report’s JSON Schema, read off what the commands print (D45)
outline, links, labels, attachmentsHow a document is found around in, as MuPDF reads it (D46): outline the bookmarks, each with title, uri, open, the page it leads to (from 1, or null) and the entries below it (down); links each page’s links, bbox in page space, uri and the target page; labels each page’s label (iv, A-3, else its number); attachments each embedded file’s name, filename, embedded, mimetype, size, created and modified (seconds since 1970), the data’s length and checksum_ok, and with --extract DIR the file it was written to (attachment-NNNN-<name>, the name made safe); annotation_attachments the same for files that FileAttachment annotations carry, with the annot id and page (annot-pN-aM-<name>)
tablesEach page’s tables as rows of cells (each cell’s text, its lines joined by newlines, and bbox; each table’s columns), found as MuPDF’s table hunting finds them with the vector paths it finds ruled tables by (stext --options vectors,table-hunt); --format csv writes the rows, a blank line between tables, and the report to stderr; --pages, --options, --threads as for extract
annotsEach page’s annotations (links, popups and widgets aside) as MuPDF lists them (D49): id (p1.a2), object, type, flags, contents, color, opacity, created, modified, and by type rect, author, subject, interior_color, icon, open, border, line, vertices, quad_points, ink_list, in page space, and for a FileAttachment the file (filename, embedded, mimetype, size). --add JSON (the listing’s fields, with page and type), --delete ID, --highlight TEXT edit them, with -o/--dry-run (nupdf.annots-edit/v1: added with each new id and object, deleted, not_deleted, unmade)
formsEach page’s widgets (D49): id (p1.w1), field name (dotted), label, type (text, checkbox, radiobutton, button, combobox, listbox, signature), flags, value, read_only, rect, and by type max_len, options (export, text), signed; the document’s need_appearances and xfa. --set NAME=VALUE and --toggle NAME fill fields, with -o/--dry-run (nupdf.fill/v1: each field’s old and new value, notes, the appearances made; scripts_run: false)
signaturesEach signature field (D47): signed, name, filter, sub_filter, reason, location, signer_name, signing_time, byte_range, contents_length, signatory, changed_since_signing, digest (“OK”, “Signature invalidated by change to document.”, …) or digest_error
flattenAnnotations and fields drawn into the pages and removed, as MuPDF bakes them (D49); --annots-only, --widgets-only; reports annots, widgets, pages, unmade (appearances that could not be made)
sanitizeJavaScript, Launch, SubmitForm/ImportData, GoToR/GoToE and XFA removed (--keep to keep some), web links by --uri keep|remove|allow:HOST,..., attachments with --attachments (D48); removed (object, path, kind, detail), counts, clean; the output is scanned again, exit 4 if anything is left
ocrScanned pages made searchable with Tesseract (D50): --lang, --dpi, --pages/--all, --min-confidence, --tesseract; per page words, chars, mean_confidence, or why it was skipped

Examples

nupdf info corpus/smoke/garbage-prefix.pdf, abbreviated:

{"schema":"nupdf.info/v1","doc_id":"b3:0b7651d98660496320e5c86a9bab07fc","file_size":619,"kind":"pdf","pdf_version":"1.6",
 "header_version":"1.6","header_offset":64,"encryption":"None","needs_password":false,"permissions":{"print":true,...},
 "page_count":1,"object_count":6,"revisions":1,"revision_offsets":[439],"linearized":false,"repaired":false,"encrypted":false,
 "metadata":{},"diagnostics":[{"code":"header.garbage_prefix","severity":"warning","message":"garbage bytes before version marker"}],
 "text":{"checked":true,"pages_with_text":1,"scanned_pages":[]}}

nupdf extract --pages 1 corpus/smoke/stext.pdf, abbreviated:

{"schema":"nupdf.extract/v1","doc_id":"b3:388a2b03971dc57d53556414a002f28d","page_count":2,"pages":[
 {"id":"p1","page":1,"bbox":[0.0,0.0,300.0,300.0],"blocks":[
  {"id":"p1.b1","type":"text","bbox":[20.0,7.09999942779541,170.7559814453125,51.5880012512207],"lines":[
   {"id":"p1.b1.l1","bbox":[20.0,7.09999942779541,84.13999938964844,23.58799934387207],"wmode":0,"dir":[1.0,0.0],"spans":[
    {"id":"p1.b1.l1.s1","text":"Hello world!","font":"Helvetica","size":12.0,"bold":false,"italic":false,"color":"#000000",
     "bbox":[20.0,7.09999942779541,84.13999938964844,23.58799934387207]}]},
   ...]},
  ...]}],
 "truncated":false,"next_cursor":null,"diagnostics":[]}

nupdf search corpus/smoke/stext.pdf 'w[a-z]+d' --options regexp, abbreviated:

{"schema":"nupdf.search/v1","doc_id":"b3:388a2b03971dc57d53556414a002f28d","page_count":2,"hit_count":2,"hits":[
 {"page":1,"id":"p1.b1.l1.s1:6-11","text":"world","quads":[[50.936004638671875,7.09999942779541,79.60400390625,7.09999942779541,...]]},
 {"page":1,"id":"p1.b1.l3.s1:5-9","text":"word","quads":[[46.68800354003906,35.099998474121094,72.69200134277344,35.099998474121094,...]]}],
 "page_errors":[],"truncated":false,"diagnostics":[]}

Redaction contract

Redaction is the command where a silent failure does the most damage, so its contract is stricter than the rest.

Inputs. --text, --regex, --preset pii|email|phone|card|ssn|iban, --region, --id, or --hits hits.json (output of search). Text matching normalizes ligatures and whitespace and crosses line breaks and hyphenation.

Apply. Removes the glyphs, image pixels and vector content inside each target’s quads. Removes or flattens annotations, widgets and links in those areas. Draws overlay boxes only if asked. Then scrubs document-wide copies of the targets, and always does a full rewrite, so no previous revision survives.

Verify is a separate pass over the output, which redact always runs and which can be run alone (nupdf verify --against targets.json). It checks these leak classes:

Leak classCheck
Content-stream textTarget text absent from extraction, including invisible text (render mode 3), text under images, white-on-white and tiny text
ActualText, Alt, structure treeAbsent from every structure element and marked-content property
Annotations and form fieldsAbsent from contents, appearance streams and field values
MetadataAbsent from Info and XMP
Outline, named destinations, page labelsAbsent from titles and names
Embedded filesAbsent from names and descriptions, and attachments removed when they contain targets
Hidden optional contentAbsent from content hidden by OCGs
ImagesPixels in redacted regions blanked
HistoryThe output has exactly one revision, and no orphan objects contain targets

The report lists, for each target, the hits before, the hits after (which must be 0) and the result of each class, plus "verified": true|false. If verification fails, redact exits 4 and does not leave the output at -o.

Command. redact <file> -o <out> with any number of --text, --regex, --preset, --region p3:[x0,y0,x1,y1] and --id targets, and --hits hits.json (a search report of the same doc_id; another document’s is refused). --ignore-case applies to text and patterns. Targets are found with the stext options --stext-options gives, by default clip=no,all-layers: clipping is off and hidden optional content is run (all-layers is nupdf’s own stext option), so text no viewer shows is found too. --pages limits the pages. --images none|remove|pixels|remove-unless-invisible (default pixels: the pixels under an area are blanked, and an image an area covers is removed; image masks and inline images included), --line-art none|covered|touched (default covered) and --boxes (fill the areas in black) choose what happens to the rest. The pages are redacted as MuPDF’s pdf_redact_page redacts them; the rest of the contract is nupdf’s (DECISIONS D40). --dry-run reports the plan, the quads by page in page space, and writes nothing; with --preview DIR it also draws each page that has targets as page-NNNN.png in DIR, the areas to be removed shaded red (--preview-dpi, 72 by default), and lists them under previews. --no-verify writes the output even when the check finds targets left, with "verified": false in the report; it is for comparisons, such as with MuPDF’s redaction. The report (nupdf.redact/v1) gives targets (each with kind, value, hits_before, hits_after), pages (each page’s quads), options, scrubbed (counts by kind), output, verification and verified.

verify <file> runs the check alone, for --text, --regex and --preset targets, or --against report.json, a redact report, whose targets it takes and whose areas it checks the image pixels under. Its report (nupdf.verify/v1) gives verification.classes, each class ok or with findings (page, object and what was found), and verified; a leak exits 4 with verify.failed.

Report schema changelog

Every report names its schema, nupdf.<command>/v<N>, and nupdf schema <command> prints that schema as JSON Schema. This file records each version.

What a version promises

Within a version, a report keeps every field it has, with the same type and meaning. These changes keep the version:

  • A new field, in a report or in anything it contains. Readers ignore fields they do not know; the schemas allow them.
  • A new value of a code (diagnostics[].code, error.code), or a new warning. Codes are stable once given: one is never reused for something else.
  • A field that was always present becoming present in more cases, or one that could be null becoming null less often.
  • Different text in message fields, which are for people.

These change the version:

  • A field removed or renamed, or given another type (including becoming nullable).
  • A value given another meaning: units, coordinate space, numbering (pages are 1-based; content ids are p3.b2.l4.s1).
  • An exit code given another meaning.

The version of each report changes on its own: a breaking change to extract makes nupdf.extract/v2 and leaves nupdf.info/v1 as it is.

v1 (2026-11-12)

The first frozen version: 27 schemas, read off what the commands print (DECISIONS D45). Every report from v0 keeps its shape; only the version in schema changed.

batch, batch.item, clean, decrypt, diff, encrypt, error, extract, extract.page, fonts, images, info, merge, meta, objects, operators, pages, redact, render, schema, search, show, split, stext, streams, trace, verify.

Additions to v1 (2026-11-12)

New reports, at v1: annots, annots-edit, attachments, fill, flatten, forms, labels, links, ocr, outline, sanitize, signatures, tables. Added fields: info has text (pages with text, scanned pages); a written document’s output has signatures when the input was signed.

v0

Reports before the schemas were frozen, while their shapes could still change.

Security

nupdf reads PDF files from anywhere, so a file that crashes it, hangs it, makes it use unbounded memory, or makes it say a file is clean when it is not, is a security bug. What nupdf promises and what it does not are in docs/THREAT-MODEL.md.

Reporting a vulnerability

Report it privately through GitHub: the repository’s Security tab, Report a vulnerability. Please do not open a public issue for it.

A useful report has the file that shows it (or how to make one), the command and options, nupdf’s version (nupdf --version), and what happened. If the file is someone’s real document, say so and do not attach it; a minimized file is better anyway (cargo +nightly fuzz tmin makes one).

You will hear back within a week. A fix goes into a release, with an advisory that credits you unless you would rather it did not.

What counts

  • A crash: a panic, an abort, a stack overflow, or any signal, on any input. nupdf has no unsafe code in its product crates, so memory corruption would be a bug in a dependency or the compiler; report it all the same.
  • A hang, or memory or time far beyond what the file’s size warrants with the default limits (--max-decoded-bytes, --max-xref-entries, --timeout).
  • redact saying content is gone when it is not, or verify saying a file has none of a text when it does. Redaction is the command where a silent failure does the most harm (docs/AGENT-INTERFACE.md, the redaction contract).
  • sanitize leaving something it says it removed: JavaScript, a launch or submit action, XFA.
  • signatures saying “OK” for a signature whose signed bytes or signed digest do not hold.
  • A file written by nupdf that another reader takes for something else than nupdf reported.
  • Anything that reaches outside the input: reading or writing files other than those named, running programs (ocr runs Tesseract, and only the program --tesseract names), network access.

What does not

  • A file that MuPDF also reads differently from Acrobat. nupdf follows MuPDF by default (DECISIONS D5).
  • Text, colours or pixels that differ from MuPDF’s. That is a parity bug: open an ordinary issue.
  • Certificate trust. signatures checks digests and RSA signatures, not whether a certificate is trusted.

Supported versions

Only the latest release gets fixes until 1.0.

Threat model

What nupdf defends against, how, and where the defence stops.

Who uses it, on what

nupdf is a command-line tool, an MCP server and a Rust library that agents and their tools run on PDF files they did not make: mail attachments, web downloads, scans, forms, contracts. The person or agent running it trusts nupdf; it does not trust the file. The file’s author may be hostile, and may know nupdf.

What a hostile file may try

AttackDefenceChecked by
Memory corruption through a parser bugNo unsafe in the product crates (unsafe_code = "forbid"); bounds-checked slices; dependencies pure Rust where they parse (zlib-rs, hayro-jpeg2000, moxcms; the JBIG2 decoder is carried in the crate)cargo deny, review of new dependencies (AGENTS.md)
A panic that kills the agent’s toolNo unwrap, expect or panic! on paths that touch input; the CLI and MCP server catch what escapes (internal.panic, exit 1) and the MCP server keeps serving19 fuzz targets, each run on every change in CI (fuzz/), the wild-tier crash sweep (cargo xtask crashes)
A decompression bomb, or a huge allocation from a small number in the fileLimits: decoded bytes per stream, xref entries, object-stream members; image and pixmap sizes checked before allocating; --max-pixels for renderingfuzz targets with RSS limits; smoke fixtures for each bomb kind
A hang: cycles in page trees, outlines, name trees, structure trees, field parents; endless content loopsEvery walk over input is bounded (depths, visited sets, step counts) where MuPDF would loop or recurse without end; --timeout (nupdf::interrupt) stops anything after a deadline, checked per operator and per pagefuzz timeouts in CI; regression files for each one found (e.g. a structure tree whose parents form a cycle)
A stack overflow from deep nestingRecursion over input is bounded; parsing of nested arrays and dictionaries is iterative or depth-limitedfuzz targets on parse_object, lex, content
Hiding text from redact (invisible text, text in forms, in annotations, in fonts’ ToUnicode, in metadata, in incremental updates)redact runs with clipping off and all layers on, removes text, images, line art, annotations and metadata by policy, writes a full rewrite (old revisions gone), and reads its output back and searches it again: anything left is exit 4the redaction contract (docs/AGENT-INTERFACE.md); the redaction tier’s leak scoring
Running code in the viewer the cleaned file goes to (JavaScript, Launch, SubmitForm, GoToR, XFA)sanitize removes them wherever they hang and scans the output again before writing it (exit 4 if anything is left)CLI tests; sanitize on the wild tier in the crash sweep
Faking a valid signaturesignatures hashes the byte ranges itself, checks the gaps between them hold only the signature, compares the signed digest and verifies RSA signatures; “OK” needs all of itthe signatures harness check against MuPDF built with OpenSSL; tampered-file tests
Making nupdf touch other filesCommands read the files they are given and write only to -o (atomically) or into a directory given; names from the file (attachments, fonts) are made safe before use and never leave that directoryCLI tests (attachment_names_cannot_leave_the_directory)
Making nupdf run programsOnly ocr runs one: the Tesseract named by --tesseract (default tesseract on the PATH), on images nupdf drew, with a time limitocr’s tests
Prompt injection through the file’s textOut of scope for nupdf itself: it reports what a file says, as data, in JSON. The agent decides what to do with it. The agent eval includes a hostile fixture to see how agents fareeval/hostile

What nupdf does not defend against

  • A hostile environment. Whoever can change nupdf’s binary, its PATH (for ocr) or the files while it runs is trusted.
  • Denial of service by size alone. A 10 GB file takes time to read. The limits bound the work per byte, not the bytes.
  • Rendering differences as an attack. A file can look different in nupdf (and MuPDF) than in Acrobat. nupdf follows MuPDF; it does not try to match every viewer.
  • Certificate trust. A signature can be valid and its certificate untrusted; nupdf does not check trust.
  • Side channels. Nothing in nupdf handles secrets an attacker could time; the RSA code verifies public signatures only.

Keeping it true

  • Every new parser surface gets a fuzz target in the same change, and CI runs each target on every pull request.
  • Each crash, hang or leak found becomes a regression test with the file that showed it.
  • The wild tier (100,000 files from Common Crawl, not sanitized) is swept for crashes before a release: cargo xtask crashes --tier wild.
  • New dependencies must be pure Rust on the deny.toml licence allow-list, and pass cargo deny’s advisories.

Architecture

How nupdf is put together, and where each part comes from in MuPDF. Decisions that shaped this are recorded in DECISIONS.

Layers

 bytes ─▶ syntax ─────────▶ document ───────────▶ content ─────────▶ consumers ──────────▶ surface
          lexer, objects    pages, resources,     interpreter        text, search,         CLI, MCP,
          xref, repair,     filters, crypt,       emitting Device    redact, render,       batch
          revisions         fonts, edit overlay   calls              write/edit

Each layer only depends on the layers to its left. Consumers never see raw bytes; they see objects, decoded streams and device calls. The surface layer is thin: every command is a library function that returns a typed report, and the CLI and the MCP server only adapt it to their transport.

Repository layout

PathWhat
crates/nupdfThe engine. One crate with modules until a split pays for itself (DECISIONS D4). #![forbid(unsafe_code)] through workspace lints.
crates/nupdf-cliThe nupdf binary. Argument parsing, JSON output, exit codes.
xtaskDev tooling run with cargo xtask: oracle, fixtures, harness, bench, corpus, and the Arlington rules the validation checks apply, from the definitions vendored in xtask/data/arlington/. It may use unsafe (it calls wait4), and it is never shipped.
oracle/MUPDF_VERSION (the pin) and js/ scripts that run under mutool run.
corpus/smoke/ and redaction/ (generated and committed), manifest.toml (public corpora, fetched on demand).
harness/baselines/Committed per-tier outcomes that the ratchet compares against.
crates/nupdf-cli/schemas/, crates/nupdf-cli/src/schemas.rsEach report’s JSON Schema, generated by cargo xtask schemas from what the commands print (D45); nupdf schema embeds them.
skills/nupdf/SKILL.mdThe Claude Code skill, nupdf guide --skill’s output (D45).
fuzz/cargo-fuzz targets, one per parser surface.
docs/Results, benchmarks, the wild tier, the interface contract and its schema changelog, architecture, testing, decisions, the threat model, releasing.
book/The docs site, an mdBook published to GitHub Pages by .github/workflows/docs.yml: getting-started pages of its own, and pages that include docs/, SECURITY.md and eval/REPORT.md as they are.

Module map

“MuPDF” lists the files a module follows. When upstream MuPDF changes one of these files, the oracle bump review (TESTING.md) checks the matching module.

nupdf moduleMuPDF source
headerpdf/pdf-xref.c (pdf_load_version)
lexpdf/pdf-lex.c
strtoffitz/strtof.c, fitz/string.c (fz_atof)
parsepdf/pdf-parse.c
objectpdf/pdf-object.c
xrefpdf/pdf-xref.c (loading), fitz/filter-basic.c (endstream filter)
slots (sparse xref storage)new; MuPDF allocates every declared slot
interrupt (stopping a call at a deadline or when cancelled)new
crypt (standard security handler)pdf/pdf-crypt.c, fitz/crypt-arc4.c, fitz/filter-basic.c (RC4 and AES filters)
repair (scanner)pdf/pdf-repair.c
document (state, loading, object streams, repair rebuild, version, page count; the edit overlay, new documents)pdf/pdf-xref.c (also pdf_update_object, pdf_xref_ensure_incremental_object, pdf_create_object, pdf_delete_object, pdf_update_stream, pdf_create_document), pdf/pdf-repair.c, pdf/pdf-page.c
diagMuPDF’s fz_warn calls, as data
text_stringpdf/pdf-parse.c (pdf_new_utf8_from_pdf_string), fitz/string.c (fz_runetochar), fitz/encodings.h (PDFDocEncoding)
stream (MuPDF’s stream model, the endstream filter)include/mupdf/fitz/stream.h, fitz/stream-read.c, fitz/stream-open.c, fitz/filter-basic.c
filter (Flate, LZW, Brotli, RunLength, ASCIIHex, ASCII85, predictors, RC4 and AES; the filter chain)pdf/pdf-stream.c (build_filter, build_compression_params), pdf/pdf-colorspace.c (pdf_guess_colorspace_components), fitz/compressed-buffer.c, fitz/filter-basic.c, fitz/filter-flate.c, fitz/filter-lzw.c, fitz/filter-brotli.c, fitz/filter-predict.c
pagepdf/pdf-page.c (page map and lookup), pdf/pdf-repair.c (pdf_walk_parent, parent repair), pdf/pdf-object.c (pdf_cycle)
fax (CCITTFax)fitz/filter-fax.c
dct (DCTDecode around the JPEG decoder)fitz/filter-dct.c
jpeg (JPEG decoder)libjpeg 10 as bundled with MuPDF: jdapimin.c, jdapistd.c, jdmaster.c, jdinput.c, jdmarker.c, jdhuff.c, jdarith.c, jaricom.c, jdcoefct.c, jddctmgr.c, jidctint.c, jdmainct.c, jdsample.c, jdcolor.c, jutils.c (NOTICE)
jbig2 (JBIG2Decode around hayro-jbig2)fitz/filter-jbig2.c, pdf/pdf-stream.c (pdf_load_jbig2_globals); jbig2dec’s refusals from jbig2.c and jbig2_segment.c
names (the names MuPDF interns, which take their own dictionary lookup path)include/mupdf/pdf/name-table.h
print (object syntax as MuPDF writes it)pdf/pdf-object.c (pdf_print_obj), fitz/printf.c (fmtfloat), fitz/ftoa.c (fz_grisu)
write (full and incremental saves, and the read-back check)pdf/pdf-write.c
rearrange (keeping some pages in a given order)pdf/pdf-clean-file.c (pdf_rearrange_pages), pdf/pdf-nametree.c (pdf_load_name_tree), pdf/pdf-page.c (pdf_flatten_inheritable_page_items), pdf/pdf-repair.c (pdf_parent_root)
edit (document information, removing metadata, page rotation)pdf/pdf-xref.c (pdf_set_metadata), pdf/pdf-parse.c (pdf_new_text_string)
garbage (garbage collection on save: marking, merging duplicates, renumbering)pdf/pdf-write.c, pdf/pdf-object.c (pdf_objcmp, pdf_hash_obj)
deflate (zlib’s compress2, for the streams clean -z writes)zlib 1.3.2 as bundled with MuPDF: compress.c, deflate.c, trees.c, adler32.c (NOTICE)
fax_encode (CCITT G4 encoder, for 1-bit images clean -z writes)fitz/encode-fax.c, fitz/buffer.c (fz_append_bits)
graft (copying objects and pages between documents)pdf/pdf-graft.c
insert (putting a page into the page tree, page labels shifted)pdf/pdf-page.c (pdf_insert_page, pdf_adjust_page_labels), pdf/pdf-repair.c (pdf_walk_parent)
geometry (points, rectangles, matrices in single precision)fitz/geometry.c
link (links read as URIs and written back: destinations, actions, file specifications, pdf_create_link)pdf/pdf-link.c, pdf/pdf-annot.c (pdf_create_link), pdf/pdf-nametree.c (pdf_lookup_dest), pdf/pdf-page.c (pdf_lookup_page_number), fitz/link.c, fitz/string.c (URI encoding, fz_cleanname)
outline (MuPDF’s outline iterator, repair pass included; the whole outline as fz_load_outline reads it)pdf/pdf-outline.c, fitz/outline.c (fz_load_outline_from_iterator)
page_label (page labels from the /PageLabels number tree)pdf/pdf-page.c (pdf_lookup_page_label, pdf_format_page_label, pdf_page_label)
embedded (embedded files: the name tree, file specifications, contents, checksums)pdf/pdf-link.c (get_file_stream_and_name, pdf_get_filespec_params and the rest), tools/murun.c (getEmbeddedFiles)
date (PDF dates as seconds since 1970)pdf/pdf-parse.c (pdf_parse_date)
merge (mutool merge: pages, external links, outline items)tools/pdfmerge.c
content (content streams lexed and handed to a processor: the operand stack, ExtGState entries, inline images, contents arrays)pdf/pdf-interpret.c (pdf_process_stream, pdf_process_keyword, pdf_process_extgstate), pdf/pdf-image.c (inline images), pdf/pdf-stream.c (pdf_open_contents_stream, pdf_open_object_array, pdf_load_compressed_inline_image), fitz/filter-basic.c (fz_open_concat), fitz/filter-leech.c, fitz/image.c (fz_decomp_image_from_stream)
colorspace (colorspace definitions: families, components, indexed and separation bases; ICC profiles judged by their header as lcms opens it)pdf/pdf-colorspace.c, fitz/colorspace.c (fz_new_indexed_colorspace, fz_new_icc_colorspace), thirdparty/lcms2/src/cmsio0.c (_cmsReadHeader), cmspcs.c (cmsChannelsOf)
run (the run processor: gstates, clips, groups and soft masks, patterns, shadings, images, forms, text and Type 3 glyphs, layers, structure, metatext; a page run; the transparency scan)pdf/pdf-op-run.c, pdf/pdf-interpret.c (pdf_tos_*, pdf_process_glyph), fitz/font.c (fz_prepare_t3_glyph, fz_bound_t3_glyph, fz_render_t3_glyph_direct), fitz/bbox-device.c and fitz/list-device.c (a Type 3 glyph’s measured box), pdf/pdf-run.c (pdf_run_page_contents, pdf_structure_type), pdf/pdf-page.c (pdf_resources_use_blending, pdf_load_default_colorspaces), pdf/pdf-pattern.c, pdf/pdf-xobject.c, pdf/pdf-struct.c (pdf_check_structure_tree), pdf/pdf-nametree.c (pdf_lookup_number), pdf/pdf-interpret.c (soft masks in pdf_process_extgstate)
device (what the interpreter draws to)include/mupdf/fitz/device.h, fitz/device.c
path (paths built and bounded as MuPDF builds them)fitz/path.c
image (images as pdf_load_image loads them, JPEG 2000 judged by its header as openjpeg reads it)pdf/pdf-image.c, fitz/image.c (fz_new_image_of_size), fitz/load-jpx.c, thirdparty/openjpeg/src/lib/openjp2/jp2.c (opj_jp2_read_header)
function (functions of types 0, 2, 3 and 4, loaded and evaluated)pdf/pdf-function.c, fitz/device.c (fz_eval_function)
shade (shadings loaded, their functions sampled)pdf/pdf-shade.c
font (font programs as FreeType opens them for MuPDF: sfnt, CFF and Type 1 faces, their charmaps, glyph names and widths; the standard 14 fonts; the CJK font’s metrics)FreeType 2: sfnt/ttload.c, ttcmap.c, ttpost.c, ttmtx.c, sfobjs.c, cff/cffload.c, cffparse.c, cffobjs.c, type1/t1parse.c, t1load.c, t1objs.c, psaux/psintrp.c, t1decode.c, base/ftobjs.c, psnames/psmodule.c (NOTICE); fitz/font.c (ft_char_index, ft_name_index), fitz/noto.c
font::outline (a glyph’s unhinted outline control box: TrueType glyphs and composites, CFF and Type 1 charstrings)FreeType 2: truetype/ttgload.c, ttpload.c, psaux/psintrp.c, psft.c, pshints.c, psstack.c, cff/cffgload.c, base/ftoutln.c (NOTICE)
fontdesc (PDF fonts: simple, composite and Type 3; the font program, encodings, ToUnicode, widths, the width table; glyph boxes)pdf/pdf-font.c, pdf/pdf-type3.c, pdf/pdf-unicode.c, pdf/pdf-metrics.c, fitz/font.c (fz_new_font_from_buffer, fz_bound_glyph, fz_bound_ft_glyph, fz_adjust_ft_glyph_width)
cmap (CMaps: embedded, predefined, lookups)pdf/pdf-cmap.c, pdf/pdf-cmap-parse.c, pdf/pdf-cmap-load.c
glyphs (glyph names to Unicode, named encodings)fitz/encodings.c, pdf/pdf-font.c (pdf_load_encoding)
tables (generated: the glyph list, encodings, bidi classes)fitz/glyphlist.h, fitz/encodings.h, fitz/ucdn_db.h
text (glyphs as devices get them, in spans; their bounds)fitz/text.c
inventory (the fonts and images the pages’ resources reach, and embedded font programs)tools/pdfinfo.c (gatherresourceinfo, gatherfonts, gatherimages), tools/pdfextract.c (savefont)
pixmap (an image decoded to 8-bit samples; PAM and PNG writers)fitz/image.c (fz_decomp_image_from_stream, fz_mask_color_key, fz_unblend_masked_tile, the JPEG height patch of compressed_image_get_pixmap), fitz/draw-unpack.c, fitz/pixmap.c (fz_convert_indexed_pixmap_to_base), fitz/output-pnm.c, fitz/output-png.c
layer (optional content: the default configuration’s states and whether a group or membership dictionary hides content)pdf/pdf-layer.c (pdf_read_ocg, pdf_select_layer_config, pdf_is_ocg_hidden)
stext (structured text: the stext device and its options, with the device’s container stack and scissors)fitz/stext-device.c, fitz/device.c (the container stack), fitz/glyphbox.c, fitz/string.c (fz_chartorune, fz_utflen), fitz/util.c (fz_new_stext_page_from_page); later stext-output.c, stext-iterator.c, stext-para.c, stext-classify.c, stext-boxer.c, stext-raft.c, stext-table.c
para (paragraph breaking: titles by underline or bold font, indents, line-height changes, trailing gaps, justified text, list items)fitz/stext-para.c
table (table hunting: candidate areas from rafts of rectangles and from segments, grids from the gaps in content and the lines drawn, cells merged and scored, Table/TR/TD structures with a grid block)fitz/stext-table.c, fitz/stext-raft.c (the flotilla)
stext_tree (the page as MuPDF’s linked lists of blocks while the passes rework it, and its block iterators)fitz/stext-iterator.c; the list handling of stext-boxer.c and stext-table.c
order (opt-in reading order: a recursive XY-cut of the blocks, D38)nupdf’s own
segment (page segmentation: the boxer’s empty rectangles, splits at the widest gaps, Div structure blocks; page fills dropped)fitz/stext-boxer.c
extract (content ids, spans, the text of each level, Markdown)nupdf’s own; the text follows fitz/stext-output.c (do_as_text, in stext)
search (search in structured text: flattening, text transforms, finders, quads)fitz/stext-search.c, fitz/util.c (fz_new_buffer_from_flattened_stext_page)
regexp (MuPDF’s regular expressions)fitz/regexp.c
unicode (categories, combining classes, decomposition, composition, case)fitz/ucdn.c, fitz/string.c (fz_toupper, fz_tolower, fz_isalpha)
content_write (content stream writer)pdf/pdf-op-buffer.c (pdf_new_output_processor), pdf/pdf-stream.c (build_compression_params)
content_filter (sanitize filter)pdf/pdf-op-filter.c (pdf_new_sanitize_filter)
content_clean (clean -c and -s)pdf/pdf-clean.c (pdf_filter_page_contents and helpers), pdf/pdf-write.c (clean_content_streams)
redactpdf/pdf-clean.c (pdf_redact_page and its filters)
redact_imagepdf/pdf-clean.c (pdf_redact_image_filter_pixels), pdf/pdf-image.c (pdf_add_image), fitz/geometry.c (quad tests)
scrub (document scrub after redaction)none; nupdf’s own
verify (leak classes)none; nupdf’s own
verifynew
draw::edge (the edge list and its anti-aliased scan conversion)fitz/draw-edge.c
draw::path (path flattening, strokes with joins, caps and dashes)fitz/draw-path.c
draw::paint (pixmaps; spans, glyphs and pixmaps painted with MuPDF’s 8-bit arithmetic; blend modes)fitz/draw-paint.c, fitz/paint-glyph.h, fitz/draw-blend.c, fitz/pixmap.c
draw::device (the draw device: clips, groups, soft masks, tiles, text, images, shadings)fitz/draw-device.c, fitz/draw-glyph.c (draw_glyph)
draw::glyph (subpixel placement, FreeType-rendered glyphs and their cache, glyph outlines as paths)fitz/draw-glyph.c (fz_subpixel_adjust, fz_render_glyph), fitz/font.c (do_ft_render_glyph, fz_outline_ft_glyph)
draw::grays (FreeType’s smooth rasterizer, which MuPDF draws glyphs with)FreeType src/smooth/ftgrays.c, ftsmooth.c, src/base/ftoutln.c, ftobjs.c (ft_glyphslot_preset_bitmap)
draw::image (subsampling, the scaler, the affine image painter)fitz/draw-affine.c, fitz/draw-scale-simple.c, fitz/pixmap.c (fz_subsample_pixmap), fitz/image.c (the subsampling factor)
draw::shade (shadings as triangles)fitz/shade.c (fz_process_shade), fitz/draw-mesh.c
draw::list (display lists, for band rendering)fitz/list-device.c
draw::color (colors into the page’s RGB, with and without color management)fitz/colorspace.c (fz_convert_color, fz_default_colorspace), fitz/color-fast.c
cms (ICC profiles through moxcms, with lcms2’s black point compensation)fitz/color-lcms.c; lcms2 cmscnvrt.c and cmssamp.c (black points)
font::outline (glyph outlines for rendering: TrueType loading at a scale, charstring contours)FreeType src/truetype/ttgload.c, src/cff/cffgload.c, src/type1/t1gload.c
annot (a page’s annotations as pdf_sync_annots lists them, and the getters)pdf/pdf-annot.c (pdf_sync_annots, pdf_annot_*), pdf/pdf-appearance.c (pdf_annot_rect_diff)
appearance (appearance synthesis: pdf_update_appearance’s rules and the shared writers), ::shapes (ink, polygons, lines, squares, circles, clouds), ::markup (text markup, redactions, carets, icons)pdf/pdf-appearance.c, pdf/pdf-annot.c (pdf_annot_ap, the border dash getters), pdf/annotation-icons.h
form (fields as the widget getters read them: type, flags, value, options, name, label)pdf/pdf-form.c (pdf_field_type, pdf_field_value, load_field_name and the rest)
bake (flattening: appearances drawn into the page, pdf_bake_document)pdf/pdf-form.c (pdf_bake_document, pdf_bake_page, pdf_bake_annot), pdf/pdf-op-buffer.c (pdf_count_q_balance), pdf/pdf-xobject.c (pdf_new_xobject)
signature (byte ranges, changes since signing, the digest check and signatory), der (DER and BER), rsa (PKCS #1 v1.5 verification)pdf/pdf-form.c (pdf_signature_byte_range, validate_byte_ranges, pdf_signature_incremental_change_since_signing), pdf/pdf-signature.c, helpers/pkcs7/pkcs7-openssl.c (check_digest, get_signatory); der and rsa are nupdf’s own
sanitize (JavaScript, launch, submit, remote and web actions, XFA, attachments removed)nupdf’s own (D48)
ocr (an invisible text layer from Tesseract’s TSV)nupdf’s own (D50)
annot_edit (annotations added and deleted, each type’s defaults, the setters, the new appearances made on update)pdf/pdf-annot.c (pdf_create_annot, pdf_create_annot_raw, pdf_delete_annot, pdf_set_annot_*), pdf/pdf-appearance.c (pdf_update_appearance), pdf/pdf-font-add.c (font objects shared by digest)
appearance::text (free text, stamps, line captions), ::layout (the text walk, line breaking, write_variable_text), ::fonts (DA parsing, the annotation’s language, base-14 and CJK font resources)pdf/pdf-appearance.c; pdf/pdf-annot.c (pdf_parse_default_appearance, pdf_annot_language); pdf/pdf-font-add.c (pdf_add_simple_font, pdf_add_cjk_font); fitz/text.c
appearance::commit (a synthesized appearance written into the document: font objects first, one per font and encoding, then the form, then /AP)pdf/pdf-annot.c (pdf_set_annot_appearance); pdf/pdf-xobject.c (pdf_new_xobject); pdf/pdf-font-add.c; pdf/pdf-resources.c (pdf_find_font_resource)
fill (form filling as the widget API fills, JavaScript off: values, check boxes and radio groups, dirty fields made again on every page)pdf/pdf-form.c (pdf_set_text_field_value, pdf_set_field_value, update_field_value, pdf_field_mark_dirty, pdf_toggle_widget, set_check_grp, pdf_update_page); pdf/pdf-page.c (pdf_load_page’s failures)
appearance::widget (text, choice and signature widgets, the unsigned signature’s sign-here arrow; push buttons, check boxes and radio buttons, a stream per state)pdf/pdf-appearance.c (pdf_write_widget_appearance, pdf_update_button_appearance); pdf/pdf-annot.c (pdf_annot_MK_BG, pdf_annot_MK_BC); pdf/pdf-form.c (pdf_button_field_on_state); pdf/pdf-layout.c (pdf_layout_fit_text)
page_run (a whole page as fz_run_page runs it: contents, annotations, widgets; which are drawn, how each appearance maps onto its /Rect, the appearances made on loading kept in the local xref)fitz/document.c (fz_run_page), pdf/pdf-run.c (pdf_run_page_with_usage, pdf_run_annot_with_usage, pdf_run_page_annots_with_usage, pdf_run_page_widgets_with_usage), pdf/pdf-interpret.c (pdf_process_annot), pdf/pdf-annot.c (pdf_annot_ap, pdf_annot_transform, pdf_annot_display_rect, pdf_annot_display_rotate), pdf/pdf-appearance.c (pdf_update_appearance’s local synthesis)
list_run (a display list played as fz_run_display_list plays it: node rectangles, clips narrowed to what they hold, culling; the text mutool draw -F txt gathers)fitz/list-device.c (fz_append_display_node, the fz_list_* rectangles, fz_run_display_list)

Core data model

pub struct Document {
    source: Source,                 // Arc<[u8]>; the file bytes, shared, never mutated
    header: Header,                 // offset ("bias") and version
    state: RwLock<Arc<State>>,      // xref entries and the object cache; replaced whole on repair
    trailer: Dict,
    revisions: Vec<Revision>,       // incremental-update sections, newest first
    diagnostics: Diagnostics,       // what was repaired or tolerated while opening
}

struct State {
    entries: Slots,                 // 16-byte entries; an array, or uniform ranges plus a map for huge tables
    cache: Cache,                   // loaded objects, in 64 shards behind RwLocks
}

pub enum Object {
    Null, Bool(bool), Int(i64), Real(Real), Name(Name), String(PdfString),
    Array(Arc<[Object]>), Dict(Arc<Dict>), Ref(ObjRef), Stream(Arc<Stream>),
}

pub struct Stream { dict: Dict, data: StreamData } // StreamData::Raw(range into source) or Owned

Real is f32, parsed by a port of MuPDF’s fz_strtof (DECISIONS D10). A Dict is a Vec of key/value pairs in insertion order, where putting a key again replaces its value in place (D11), and a dictionary of more than 100 entries is sorted by key, as MuPDF sorts it (D18). A Name is its bytes in an Arc<[u8]>, not interned. Object values are cheap to clone (shared Arcs), so handing them across threads is free.

Editing never mutates a Document. An Edit holds an overlay map from ObjRef to new objects, plus new object numbers, over an immutable base. The writer serializes the overlay either as a full rewrite or as an incremental update. Readers on other threads keep working against the base while an edit is in progress.

Concurrency

MuPDF needs a cloned fz_context per thread and serializes access to a document. Its documented pattern for parallelism is to build display lists on one thread and rasterize them on others. nupdf instead makes the document itself shareable:

  • Document: Send + Sync. Object resolution is lazy. Loaded objects go into a cache split into 64 shards, each behind an RwLock, so readers rarely contend. Two threads that race to load the same object may both parse it, and the first result stored wins. A repair builds a new State and swaps it in; readers holding the old one finish against it.
  • Shared caches (fonts, decoded images, glyphs) are concurrent maps with size bounds and are keyed by ObjRef.
  • Page-level operations (extract, search, render, redaction planning) fan out over pages on scoped threads (parallel, D37). Each page has its own resource store and its diagnostics are captured, then reported deduplicated in a fixed order; results are collected by page index. Output is identical at every thread count, and tests enforce that.
  • batch adds file-level parallelism on top. The MCP server runs each call on a thread of its own and keeps files’ bytes and ids between calls (D42); documents are parsed for each call, so reports equal the CLI’s.
  • Rendering also splits a page: a page of a megapixel or more, with threads to spare, is recorded once as a display list (draw::list) and played into horizontal bands on separate threads, each band with the pixels of the page drawn whole (D41).

Safety and limits

  • unsafe_code = "forbid" in every product crate. Dependencies may contain audited unsafe (zlib-rs, blake3), but nupdf’s own code has none.
  • No panics on input. unwrap, expect and panic! are lint warnings (errors in CI) outside tests. Fuzzing catches the rest: out-of-bounds slicing, arithmetic overflow and stack overflow.
  • No unbounded recursion. Nested arrays and dictionaries, Form XObjects, reference chains and page trees all carry explicit depth limits, because a stack overflow aborts the process.
  • A Limits value travels with every document (Document::open_with): the decoded size of a stream, the xref size and the size of an object stream, defaulting to MuPDF’s own caps. Rendering has its own pixel cap (--max-pixels). Time and cancellation are an Interrupt that the thread running a call carries (interrupt: a flag set from outside, or a deadline), checked at each content-stream operator and each page. Structural depths (object nesting 512, page tree and reference chains 256) are fixed constants, because their purpose is to keep the stack safe.
  • PDF JavaScript is never executed.

Errors and diagnostics

Two channels, kept apart deliberately:

  • Result<T, Error> only for failures that stop the operation (unreadable file, wrong password, limit exceeded). Errors have stable codes such as input.not_pdf and limit.decompressed_size.
  • Diagnostics for everything that was tolerated, repaired or skipped: xref.repaired, font.missing_tounicode, stream.length_mismatch. Each carries a severity and a location (page, object, byte offset). They are MuPDF’s fz_warn turned into data. Reports include them, so an agent knows when a result is degraded and why.

Dependency policy

Pure Rust, widely used, permissive licenses compatible with AGPL (cargo-deny enforces the allow-list). No C libraries in product crates; Tesseract runs as a separate process. What each need uses:

NeedCrate
Deflatezlib-rs to decode; own port of zlib’s deflate to encode (DECISIONS D20)
LZWown port of MuPDF’s decoder (DECISIONS D16)
Brotlibrotli-decompressor
JPEGown port of libjpeg (DECISIONS D17)
JBIG2hayro-jbig2 0.4.0, carried in crates/nupdf/src/hayro_jbig2 with guards against endless loops on damaged data (its NUPDF.md), since crates.io does not apply a workspace’s patches
CCITTown port of MuPDF’s decoder
JPEG 2000hayro-jpeg2000
Fontsown port of the parts of FreeType MuPDF uses: TrueType and OpenType, CFF, Type 1, outlines and the glyph rasterizer (DECISIONS D32, D34, D41)
Color managementmoxcms
Rasterizerown port of MuPDF’s draw code (DECISIONS D41)
CryptoRustCrypto: aes, md-5, sha1, sha2 (CBC and RC4 are small enough to write, and MuPDF’s padding and error rules need them open-coded); RSA for signature checks is nupdf’s own (rsa)
Parallelismthe standard library’s scoped threads (parallel, DECISIONS D37)
Hashing (doc ids)blake3
OCR (optional)Tesseract as a subprocess (DECISIONS D50)

hayro’s own crypto is marked unaudited by its author, and nupdf also needs to encrypt, so it uses RustCrypto instead.

Determinism

Same input, arguments and nupdf version give byte-identical JSON and PDF output. That means no timestamps unless asked for, /ID derived from content, object numbering and dictionary key order that are stable, and parallel results ordered by page. The harness and the agent eval both rely on this.

Testing

nupdf’s claims (parity, safety, speed, agent usefulness) are only as good as the measurements behind them. This file describes each layer of testing, how to run it, and how results are ratcheted.

Commands

cargo test --workspace                  # unit, integration and fixture-freshness tests
cargo xtask oracle build                # build the pinned MuPDF once (about 15s on an M-series Mac)
cargo test --workspace -- --ignored     # tests that need the oracle
cargo xtask fixtures                    # regenerate corpus/smoke (add --check to verify)
cargo xtask harness                     # differential checks on the smoke tier
cargo xtask harness --tier standard     # same on fetched public corpora
cargo xtask harness --update-baseline   # accept improved results
cargo xtask bench --runs 5              # paired timing and memory against mutool
cargo xtask bench --tier standard --sample 300 --runs 3   # evenly spaced sample of a big tier
cargo xtask bench --tier standard --sample 200 --runs 3 --against pdf_oxide   # MuPDF, nupdf and pdf_oxide
cargo xtask bench --tier standard --sample 200 --runs 5 --against engines     # nupdf's library against pdf_oxide's
cargo xtask corpus list                 # what is fetched, sizes, licenses
cargo xtask corpus fetch qpdf           # sparse clone of one source
cargo xtask crashes --tier wild         # nupdf alone on every file: crashes, internal errors, timeouts
cargo llvm-cov --workspace --summary-only --ignore-filename-regex '(nupdf-cli|xtask|fuzz)/'   # line coverage of the nupdf crate (at least 90%)

Layers

LayerWhat it provesWhere
Unit testsEach function’s behavior, including MuPDF’s edge casesnext to the code
CLI integration testsThe JSON contract, exit codes and error codescrates/nupdf-cli/tests
Smoke fixturesGenerated PDFs, one feature or failure each, committed and checked for freshnesscorpus/smoke, xtask/src/fixtures.rs
Differential harnessSame results as MuPDF, file by file, on real corporaxtask/src/harness.rs, oracle/js
Property testsRound trips: serialize then parse is the identitycrates/nupdf
FuzzingNo panics, hangs or unbounded allocation on any inputfuzz/
BenchmarksSpeed and peak memory against MuPDF and othersxtask/src/bench.rs
Agent evalAgents do better with nupdf than with the alternativeseval/

The oracle

  • The pin is oracle/MUPDF_VERSION (1.28.6). cargo xtask oracle build shallow-clones that tag with submodules into target/oracle/mupdf-<version>/ and builds the command-line tools without X11, GLUT or curl.
  • NUPDF_MUTOOL=/path/to/mutool overrides the location, but the binary must report the pinned version. Results are only comparable within one MuPDF version.
  • The oracle runs out of process: a MuPDF crash or hang becomes a classified outcome, not a harness failure.
  • Oracle scripts live in oracle/js/ and run as mutool run <script> <file>. They print one JSON object whose field names match nupdf’s, so the comparison is a field-by-field equality, with tolerances where a check defines them.
  • The MuPDF source tree in target/oracle/ is also the reference for porting work.
  • The qpdf-* checks also need qpdf on PATH, or NUPDF_QPDF=/path/to/qpdf. Any recent version does: both sides’ output is checked by the same binary, so its messages only have to agree with themselves. CI installs Ubuntu’s.

Bumping the oracle: change MUPDF_VERSION, rebuild, rerun every tier, and review upstream changes to the files in ARCHITECTURE’s module map. Then re-baseline with --update-baseline in a commit that explains the differences.

Corpus tiers

TierContentsWhere
smoke106 generated fixtures, one per syntax feature, leniency or repair pathcommitted in corpus/smoke
standardpdf.js, qpdf, PDFium, veraPDF, py-pdf sample files, pdf-differences, pdf20examples: about 5k filesfetched into $NUPDF_CORPUS_DIR
redactionpdf-redaction-benchmarks, plus nupdf’s generated leak fixturesfetched
wildSamples of GovDocs1 and SafeDocs CC-MAIN-2021-31downloaded by hand

Third-party PDFs are never committed; corpus/manifest.toml records each source’s URL, license and purpose. The wild tier contains malformed and malicious files. Neither tool executes PDF JavaScript, but run wild-tier jobs on a machine you don’t mind exposing to hostile parsers’ bugs.

Checks

CheckComparesTolerance
infoEffective version, whether a password is needed, encryption, page count, xref size, repaired, revisions, permissions, Info metadataexact
objectsEvery object in canonical JSON (names #xx-escaped, strings hex, reals at f32), plus raw stream lengthsexact (numbers per DECISIONS D13)
pagesPage lookup for every index and inherited MediaBox, CropBox, Rotate and Resources; page count before and after lookupsexact
warningsDistinct warning: and <kind> error: lines on MuPDF’s stderr against nupdf’s diagnostic messages, while running infoset equality
streamsBLAKE3 of each decoded streamexact, except image filters
cleanBLAKE3 of the clean output against MuPDF’s save in reproducible mode, the writer’s comment line swappedexact
clean-g … clean-ggggThe same at each garbage collection levelexact
clean-d, clean-z, clean-ze, clean-a, clean-dfi, clean-dzggThe same with stream options: decompress, compress (at the default effort and at 100), hex, decompress all but fonts and images, and decompress and compress after renumberingexact
clean-Z, clean-Zg, clean-ZggzThe same with object streams and an xref stream: alone, at -g, and with -gg and -zexact
clean-DThe same without encryptionexact
clean-pages-reverse, clean-pages-first, clean-pages-restThe same keeping pages N-1, 1 and 2-N (pdf_rearrange_pages), every object loaded first as mutool clean doesexact
roundtrip-E40, roundtrip-E128, roundtrip-Eaes128, roundtrip-Eaes256MuPDF reading back each side’s output encrypted with a new handler (owner password only): objects decrypted, raw stream data, repair, warnings. Bytes differ by design: MuPDF’s new /ID and AES secrets are randomexact
roundtrip, roundtrip-ggggMuPDF reading back each side’s clean (or clean -gggg) output: objects, raw stream data, repair, warningsexact
traceDevice calls per page, against mutool tracematrices within 1e-3
stextEach page’s blocks, lines and characters (text, font, flags, bidi level, origin, size, quad) against Page.toStructuredText walked by oracle/js/stext.js, with default options; the summary adds two measures (pages with MuPDF’s text, characters with box IoU ≥ 0.98)same structure and text, IoU ≥ 0.98
text, text-dehyphenateEach page’s text from extract --fields text against asText() (fz_print_stext_page_as_text), with default options and with dehyphenateexact
roundtrip-textMuPDF reading back each side’s clean output: each page’s text, as text gives itexact
search, search-icase, search-regexpEach page’s hits of the; of THE ignoring case and diacritics; of [0-9]+ as a regular expression; against StructuredText.search (oracle/js/search.js), each hit’s quadsnumbers within a few float steps
stext-preserve, stext-rawThe same with ligatures, whitespace and spans kept, images and shadings as blocks and hyphens joined; and with no added spaces, CIDs for unknown Unicode, ActualText ignored, no clipping and outline boxessame structure and text, IoU ≥ 0.98
redactRemaining text against MuPDF’s redaction; zero targets for nupdf, MuPDF and pdftotextexact
renderPixels against mutool draw at 72 and 150 dpithreshold per page

Outcomes and the ratchet

Each file in a check gets one outcome:

OutcomeMeaning
matchBoth succeeded and every compared field agrees
mismatchBoth succeeded and some field differs; the report shows each field’s two values
nupdf_failedMuPDF handled the file and nupdf did not
oracle_failednupdf answered and MuPDF did not. This is not automatically good; each one gets reviewed
both_failedBoth rejected the file
timeoutEither side exceeded --timeout
crashEither side died from a signal. For nupdf this is always a bug

Some checks validate output instead of comparing it: qpdf-* run qpdf --check on each side’s output and compare its verdict and messages (qpdf must be installed, or NUPDF_QPDF set to it), and arlington-* apply selected Arlington PDF Model rules to the dictionaries nupdf writes (DECISIONS D28). Either way nupdf’s output matches when the validator says the same of it as of MuPDF’s. cargo xtask arlington <files> prints the violations it finds in any file.

Parity is match divided by the files MuPDF handled. Reports go to target/harness/<tier>/ (summary.md plus one JSON file per check, with MuPDF’s warnings per file). A capital in a check’s name becomes _ and the letter in its file name, so clean-Z and clean-z stay apart on a case-insensitive filesystem.

harness/baselines/<tier>.json records each file’s outcome. A run fails if any file that matched in the baseline no longer matches. Newly matching files are reported, and --update-baseline locks them in. A baseline recorded with a different oracle version is refused.

Benchmarks

  • Pairs run the same task with mutool and with nupdf, end to end as processes, because that is what an agent pays for. One warmup run, then N timed runs. The report gives median wall time, peak RSS (from wait4) and the geometric-mean speedup.
  • Runs are sequential, on the local bench machine. CI runners are too noisy to gate on.
  • For context, --pair text-context times every page’s text as plain text by nupdf, MuPDF, pdftotext (when on PATH) and PyMuPDF, pypdf, pdfplumber, pdfminer.six and pdf_oxide from target/bench-venv, each as a process, so the agent-facing comparison is concrete.
  • --against pdf_oxide (xtask/src/against.rs, oracle/bench/oxide.py) runs the operations pdf_oxide also has (info, pages, text, text-fast, render, render-png, tables, annots, forms, signatures) on the same files with MuPDF, nupdf, pdf_oxide’s native CLI where it has a command for the operation (info, text, render-png, forms; pdf-oxide from cargo install --locked --root target/oxide-cli pdf_oxide_cli, or NUPDF_PDF_OXIDE; left out if missing) and pdf_oxide’s Python bindings from target/bench-venv. The CLI is timed as a process; Python as a process and in process (opening the document to its last byte of output, the interpreter and import left out); nupdf’s in-process time is its process time less the fastest of 21 runs of nupdf --version, and at least 0.5 ms. A side that exits with an error or runs over 300 s, or in Python raises on a page, has failed on the file. Where nupdf’s and pdf_oxide’s outputs must agree (page count, whether a file has text, how many pages have ink), a file on which they do not is counted apart. Each ratio is over the files both of its tools did (and, for pdf_oxide, agreed on). Reports go to target/bench/<tier>/against-pdf_oxide/; numbers are in BENCHMARKS.
  • --against engines (xtask/src/engines.rs, bench/engines) times nupdf’s library against pdf_oxide’s, Rust to Rust: bench/engines is a crate outside the workspace, so no product crate depends on pdf_oxide, built with the root’s release profile. For each file, operation (open, pages, text, text-fast, render, tables, annots) and library it runs a child process that reads the file, does one checked warm-up run and then N timed runs, each opening the document from memory and doing every page, and prints the median. An error, a panic, an abort or a hang (600 s) is that library’s failure on the file. The same output checks as above decide which files count; reports go to target/bench/<tier>/engines/.
  • Scaling runs repeat each page-parallel command at 1, 2, 4, 8 and N threads.
  • Results that support public claims are committed under bench/results/ with machine details.

The info pair is not like for like: nupdf’s info also scans every page’s text for pages that need OCR, and MuPDF’s side runs a script under mutool run, its JavaScript runtime included.

Fuzzing

cargo +nightly fuzz run -O open_walk -- -max_total_time=3600   # one CPU-hour
cargo +nightly fuzz run -O lex                                  # until stopped

Targets live in fuzz/fuzz_targets: lex, parse_object, filters, crypt, images, pixmap, function, cmap, font, content, run, regexp, navigation, forms, redact, render, stext (pages gathered into structured text with options the first byte picks), encode (the deflate and CCITT G4 encoders clean writes with, each output decoded back) and open_walk (open a document, compute its version and page count, load every object, decode every stream, and write it as clean does with options the input picks). Seed fuzz/corpus/open_walk/ with the smoke fixtures and small standard-tier files; corpora and crash artifacts are not committed.

  • cargo-fuzz targets per parser surface: lex, parse_object, open_walk, one per decoder, the content parser and the font parsers.
  • Seeds come from the smoke and standard tiers.
  • Every crash becomes a smoke fixture or a unit test before it is fixed.
  • CI’s fuzz job builds every target and runs each for 30 seconds on every pull request and every push to main (it is not a required check). Longer runs, an hour a target or overnight, happen locally.
  • oss-fuzz/ holds the project files for OSS-Fuzz, ready to send once the repository is public.

Agent eval

About 30 tasks with automatic graders, run with Claude Code in a clean environment per arm. Arms: nupdf CLI, nupdf MCP, the default Python PDF skill (pypdf and pdfplumber), pymupdf4llm-mcp, and pdf_oxide. Metrics: graded success, wall time, tool calls and tokens. Task files, graders and results are committed, so anyone can rerun them.

CI

  • ci.yml on every push and pull request: rustfmt, clippy with warnings as errors, cargo test --workspace, fixture freshness, and cargo-deny (licenses, advisories, sources).
  • harness.yml on every push, pull request and manual dispatch: builds the oracle (cached by version), runs the oracle-dependent tests and the smoke-tier harness, and uploads the reports.

Decisions

Each decision records what was chosen and why. Newer decisions can supersede older ones; when that happens the old entry stays and is marked as superseded.

D1. License: AGPL-3.0-or-later

2026-11-09

nupdf follows MuPDF’s design, algorithms and edge-case behavior closely, and its authors read MuPDF’s source while writing it. That makes it a derivative work, and MuPDF is AGPL-3.0-or-later, so nupdf is too. Calling it a clean-room implementation would be false, and choosing AGPL lets us read MuPDF openly.

What this means:

  • Anyone can use, modify and redistribute nupdf under the AGPL. Running it as a network service obliges you to offer the source to its users.
  • Programs that call the nupdf CLI or MCP server as a separate process are generally not bound by the AGPL. Programs that link the nupdf crate are.
  • A permissive or commercial relicense would need Artifex’s agreement, because they hold the copyright on what we derive from. We would have to ask them, and that is fine.
  • Files that follow a MuPDF file name it and carry Artifex’s copyright line (see NOTICE).

D2. A rewrite, not a port and not a wrapper

2026-11-09. Supersedes the earlier idea of shipping an agent CLI over MuPDF’s C library first.

Memory safety and real parallelism are architectural properties. A thin layer over MuPDF’s C would keep MuPDF’s memory model and its per-thread contexts. A line-by-line translation (c2rust or similar) would reproduce fz_try/fz_catch, manual reference counting and context threading in unsafe Rust. So nupdf is written as idiomatic Rust that uses MuPDF as the behavioral reference and as the source of algorithms, and contains no C.

D3. The oracle is a pinned MuPDF, used out of process

2026-11-09

MuPDF 1.28.6 is built from source by cargo xtask oracle build and driven through mutool run (its JavaScript API), mutool trace and mutool draw.

  • Out of process means MuPDF crashes and hangs become classified outcomes, nothing C is linked into the workspace, and the same binary serves both the harness and the benchmarks.
  • Pinned, built from source, and version-checked on every use means results are reproducible and baselines are meaningful.
  • The JavaScript API reaches almost everything the C API does, so new checks are short scripts in oracle/js/.

D4. One library crate until a split pays for itself

2026-11-09

Starting with many crates would freeze boundaries before we know them. nupdf stays a single crate with modules. A split happens when it buys something measurable, such as compile time or keeping heavy optional dependencies (rendering, OCR) out of the core. This is revisited at M4 and M7.

D5. Default behavior matches MuPDF; improvements are opt-in modes

2026-11-09

The harness is only meaningful if the default output is supposed to equal MuPDF’s. Where nupdf can do better (reading order, stricter validation, recovering files MuPDF rejects), the better behavior ships as a named mode, measured against ground truth rather than against MuPDF. Bugs in MuPDF that we choose not to reproduce are listed, with the file that shows them, in the check’s documentation.

D6. A JSON contract from day one

2026-11-09

Every command returns a versioned report (schema), with doc_id, diagnostics and stable exit codes, from its first version. Schemas are v0 and may change until M8 freezes v1. The contract is in AGENT-INTERFACE.md.

D7. Pure-Rust dependencies, licenses enforced by cargo-deny

2026-11-09

Dependencies must be pure Rust (they may contain audited unsafe internally) with AGPL-compatible licenses. External tools such as Tesseract run as subprocesses, never linked. For crypto, nupdf uses RustCrypto rather than hayro’s unaudited implementation, because it must also write encrypted files.

D8. BLAKE3 for document identity

2026-11-09

doc_id is the first 128 bits of the file’s BLAKE3 hash, written b3:<32 hex>. BLAKE3 is fast enough to hash large files on every call, and the prefix makes the algorithm explicit if it ever changes.

D9. Name: nupdf

2026-11-09

ν (nu) is the Greek letter after μ (mu). The name was free on crates.io and GitHub. It does not use the MuPDF trademark.

D10. Reals are f32, parsed by MuPDF’s own algorithm

2026-11-09. Settles the plan’s open question 1.

MuPDF stores reals as float, and its fz_strtof is not correctly rounded: it keeps 9 significant digits and uses Coonen’s Algorithm D with a 32-bit significand. Parsing with Rust’s correctly rounded f32::from_str made dozens of standard-tier files differ by one ulp. nupdf ports strtof.c exactly (crates/nupdf/src/strtof.rs), including fz_atof’s rule that an underflow returns 1.0. Geometry code may still compute in f64 internally, as long as what it reports matches MuPDF.

D11. Dictionaries keep insertion order, with replace-in-place

2026-11-09. Settles the plan’s open question 2 for now.

A Vec of key/value pairs with linear lookup, where re-inserting a key replaces its value in place (pdf_dict_put’s behavior, so the last duplicate wins). Real dictionaries are small. MuPDF sorts dictionaries over 100 entries; nupdf will add an index if profiles show large dictionaries matter. Revised by D18: the order is what gets written, so nupdf sorts them too.

D12. Small filters move from M2 into M1

2026-11-09

Cross-reference streams and object streams in the standard corpus use ASCIIHex as well as Flate, and the parity work for M1 could not be measured without decoding them. ASCIIHex, ASCII85, RunLength, Flate and the predictors are therefore part of M1. LZW, Brotli, the image decoders and the security handler stay in M2.

D13. How the harness compares numbers

2026-11-09

Integers compare exactly. Non-integers compare at f32 precision, because MuPDF’s values are floats and mujs prints only 16 significant digits. Integers beyond 2^53 compare to within 1e-15 relative, for the same reason. Oracle scripts avoid lossy MuPDF JavaScript accessors (asNumber squeezes integers through a 32-bit int and a float) and print names in MuPDF’s #xx-escaped form, so the JSON stays valid when names contain arbitrary bytes.

D14. Xref storage follows the file, not the declared size

2026-11-09

MuPDF allocates a 40-byte entry for every slot a section declares, in every section. A few hundred bytes can declare 8M objects (a large object number, or an xref stream with zero-width fields), and each /Prev can declare them again. That is a memory amplification any hostile file can trigger, so nupdf does not copy it.

Each section stores what it read as runs: listed entries one by one, and long stretches of identical entries once. The effective table is an array up to 1M slots, or when listed entries are at least a quarter of it. Above that it is a set of uniform ranges plus a map. Free and unset slots are computed, not cached, and objects writes its report while it loads, so its memory does not grow with the report.

Behavior stays MuPDF’s (D5). The object count, the entries and the errors are the same. MuPDF checks entries in the order of its in-memory subsection list, which its merging rules shape: a subsection inside another is ignored, one that starts inside or right after another extends it, one that touches two makes the section a single subsection, and so does an xref stream whose own number lies outside its /Index. That order decides which error a broken file reports, so nupdf models the list on number ranges instead of allocating it. The rules are tested against MuPDF’s output on constructed files.

D15. Encryption moves from M2 into M1

2026-11-09

Encrypted files were almost all of what kept M1 from its exit bar: 68 of the 69 info mismatches on the standard tier, most of objects and a third of warnings. Agents also meet them constantly, usually with only an owner password set, which every reader opens silently. So the standard security handler (revisions 2 to 6, RC4 and AES, crypt filters) is part of M1. LZW, Brotli and the image decoders stay in M2.

nupdf copies MuPDF’s handling, including two behaviors that look like accidents. The file key is state that each authentication attempt overwrites, so a document opened without its password is decrypted with the key from the last failed attempt, and reports the same unreadable strings MuPDF does. And MuPDF decrypts AES as a stream: a bad final block ends the data where a reader reaches it, so Flate usually fails first on a wrong key, and a whole-stream read keeps the 256-byte chunks it got before the error. Both decide what agents see in a file they cannot open, and both are tested against MuPDF.

Passwords are given with --password. A wrong one is an input error (exit 3, input.wrong_password); no password is not an error, since MuPDF opens such files too, and info reports needs_password.

D16. Streams are read through a port of MuPDF’s stream model

2026-11-10

What a damaged stream yields in MuPDF is decided by its stream machinery rather than by any one filter. Each stage fills a buffer of a fixed size (4096 bytes for Flate, LZW, Brotli and predictors, 256 for ASCIIHex, ASCII85, RunLength, RC4 and AES). A stage that fails loses the buffer it was filling, and the reader above treats the failure as the end of the data after reporting it. The whole-stream reader asks for at most what is left in its own growing buffer. A stage can also mark itself finished while still holding bytes, which fz_available then drops and fz_read_byte does not. M1 approximated these rules case by case (the AES chunks, the endstream window). M2 ports the model itself (stream) with each filter as a stage, so they hold for every filter and every combination.

LZW is a port of MuPDF’s decoder rather than a library: its tolerance (a missing clear code, a single out-of-range code) and its reading past the end of a truncated stream are part of what it returns. Brotli uses brotli-decompressor, a port of the same reference decoder MuPDF links. One difference is deliberate: on a truncated Brotli stream MuPDF loops forever waiting for input, and nupdf ends the data instead.

D17. JPEG is a port of libjpeg; JBIG2 uses hayro-jbig2 with jbig2dec’s refusals

2026-11-10

The plan named zune-jpeg for DCTDecode. Measured against MuPDF it was close but not exact: 95.8% of files matched within the old tolerance, and 115 JPEGs differed by up to a dozen levels. jpeg-decoder did no better (95.9%). The cause is libjpeg itself. Since version 7 it upsamples subsampled chroma inside the inverse DCT (a 2x2-subsampled block goes through a 16x16 transform), where other decoders upsample afterwards; its integer IDCT also clamps the DC term and rounds in its own way. So nupdf ports libjpeg 10’s decompressor, the copy MuPDF bundles (jpeg, NOTICE). The port is limited to how MuPDF drives libjpeg: default parameters, full scale, one scanline at a time, a source that never suspends. That still covers baseline, extended and progressive Huffman and arithmetic coding, restart resynchronization, block smoothing of incomplete progressive images, and libjpeg’s handling of truncated data. Every DCT stream in the standard tier now matches MuPDF byte for byte, so the images check compares exactly rather than within a level.

CCITTFax is a port of MuPDF’s filter-fax.c, which also matches exactly.

JBIG2 uses hayro-jbig2. Porting jbig2dec (17,000 lines) was not needed: on valid streams the two decoders agree. Where they do not, jbig2dec is usually the one refusing. It leaves parts of the standard unimplemented (intermediate generic regions, 12 adaptive template pixels, color extensions, reused coding contexts) and fails the whole stream on them, so MuPDF yields no image. nupdf scans the segment headers for those parts first, then fails the same way and reports jbig2dec’s messages. It also accepts a JBIG2 file header in a PDF stream, as jbig2dec does. 18 files still differ, all of them test files for the corner cases:

  • 14 pdf.js refinement tests, where jbig2dec mis-decodes Huffman-coded text regions.
  • 3 where jbig2dec’s XNOR composition sets the padding bits past the end of each row.
  • bomb_giant.pdf, whose 100,000 x 100,000 page MuPDF allocates before its compression bomb check stops it, and nupdf refuses.

JPX passes through undecoded in pdf_load_stream, as in MuPDF; decoding it belongs to image loading (M4).

The fuzzer found two loops in hayro-jbig2’s symbol dictionary decoding that never end on damaged data, because its arithmetic decoder keeps returning values past the end: endless empty export runs, and endless empty height classes. jbig2dec stops the first after 1000 empty runs. nupdf carries hayro-jbig2 0.4.0 with that guard and the same one for height classes, until upstream has an equivalent: first as a vendored crate, and since 0.1.0 as a module of the nupdf crate (crates/nupdf/src/hayro_jbig2/NUPDF.md), because crates.io does not apply a workspace’s patches. Decoding time also follows the symbol sizes a dictionary declares, and a 600-byte stream can declare tens of thousands of large symbols: MuPDF took more than five minutes on one the fuzzer found. The carried copy caps a dictionary at 2^28 pixels of symbols, far more than real dictionaries hold.

D18. The writer matches mutool clean byte for byte

2026-11-10

nupdf clean writes what pdf_save_document writes with default options, and the clean check compares the two files’ BLAKE3 hashes. Matching bytes needs MuPDF’s printer exactly: /Type and /Subtype first in tight mode, a space only between two regular characters, its choice between literal and hex strings, #XX names cut at a NUL, and reals through fmtfloat with its port of Grisu2 (print). It also needs these behaviors of MuPDF’s model:

  • Dictionary order. A put into a dictionary of more than 100 entries sorts it by key, and later keys go into their sorted place (pdf_dict_get_put); a delete moves the last entry into the gap and unsorts it. This revises D11, since the order is what gets written. Lookups take one of two paths. A name MuPDF interns as a constant (608 of them, names) goes through pdf_dict_find, which in a sorted dictionary reports the last key as absent when it is the key sought. Any other name goes through pdf_dict_finds, which does not. So a sorted dictionary whose last key is /Type does not get /Type moved to the front.
  • Null is absent. MuPDF’s null object is the null pointer, so a null /Type, /ID or /Info counts as missing wherever it checks for an object.
  • Reals that are integers. MuPDF writes a real as an integer when (int)f converts back to it, a conversion C leaves undefined outside the int range. x86 gives INT_MIN there and ARM saturates, so MuPDF writes 2147483648.0 as 2147483700 on x86 and as 2147483647 on ARM. nupdf follows x86, which loses nothing, and the fixtures avoid that one value.
  • Generations are 16 bits. Xref generations are unsigned short, so 65536 reads as 0, and the free list then counts up from what was read.
  • Objects that cannot be loaded. Object stream members that parse as null, or fail to load, are written as free entries, with MuPDF’s generations.

MuPDF uses random AES initialization vectors. nupdf derives each one from the key, the object and the data (the first 16 bytes of SHA-256 over them), so its output is deterministic (AGENT-INTERFACE rule 10). AES-encrypted files therefore differ from MuPDF’s in those 16 bytes per string and stream, and the clean check counts them as mismatches. The comment after the header names nupdf instead of MuPDF (same length; the harness swaps it before hashing).

The roundtrip check covers what bytes cannot. Each side writes its file, MuPDF reads each one back, and the harness compares the object graph, the raw stream data, whether a repair was needed, and MuPDF’s warnings. Its remaining mismatches also come from random IVs, where MuPDF’s own output does not read back exactly: AES streams of a document opened without its password, which MuPDF writes empty with a 32-byte /Length, and streams under an Identity crypt filter, which it encrypts anyway. On issue20453.pdf, MuPDF’s save fails after a repair because it parses an object stream’s number as a file offset; nupdf writes the file.

clean then reads its own output back and refuses to write one that differs from the input (exit 4), as AGENT-INTERFACE requires of mutations. That rejects some of MuPDF’s lossy cases (an empty key name before a number, Identity crypt filters, documents opened without their password: 27 standard files). --no-verify writes MuPDF’s output regardless, and the harness uses it.

D19. Garbage collection keeps MuPDF’s quirks, and its read-back follows references

2026-11-10

clean -g through -gggg run MuPDF’s do_garbage on the writer’s copy of the objects (garbage), and the clean-g to clean-gggg checks compare the output’s BLAKE3 with MuPDF’s at each level. Matching it means doing what MuPDF does, including where that loses data:

  • Marking. Only what the trailer reaches is written. A reference to a missing, free or null object becomes null where it appears, and so does a reference to a reference to nothing, because pdf_is_null follows the chain. An indirect stream /Length becomes its number first, so the object that held it is dropped.
  • Duplicates (-ggg) are found by zlib’s CRC-32 over each object as MuPDF holds it in memory: null, true, false and interned names hash as their small pointer values, integers as 64 bits, reals as 32. Objects with the same hash are then compared with pdf_objcmp. Two dictionaries with the same entries in another order hash differently, so they are not merged, although the comparison would call them equal. Pages are never merged, and streams only when -gggg compares their data. Merging repeats until nothing changes, since renumbering can make more objects equal.
  • Renumbering (-gg) gives every object a new number from 1 and generation 0, and encrypts it again under the new number. Each changed reference goes back into its dictionary with pdf_dict_put, by key. A dictionary of 101 entries is sorted by that put, so its key order changes, and an entry the sort moves past the loop’s position is renumbered a second time, ending up pointing at another object. In a sorted dictionary whose last key is an interned name, the put appends a duplicate key with the new number and leaves the old one in place (D18’s lookup quirk).
  • Truncation. When nothing is reachable, because the trailer has no /Root, -gg writes an empty xref that neither tool can open.

On the standard tier all four levels match MuPDF on every file except the 33 AES files clean differs on (random IVs, D18). Collecting also lets both tools write 37 files that fail without it, because the objects that cannot be loaded are ones nothing reaches. roundtrip-gggg covers the AES files, as roundtrip does for clean.

With garbage collection, objects can be renumbered or merged, so clean’s read-back check cannot compare them by number. It walks both documents from the trailer’s /Root, /Info, /ID and /Encrypt, pairing objects where the same reference leads, and fails if an object pairs two ways or reads back differently. Merged objects may share a partner, and a reference that reads as null in the input may be null in the output. The check refuses the renumbering quirks above and the empty outputs: 20 standard files at -g (all also refused without -g) and 53 at -gggg, 33 of them empty.

D20. Stream options follow copystream and expandstream, with zlib’s own deflate

2026-11-10

clean -d, -z, -f, -i, -a and -e decide each stream as MuPDF’s writeobject does, and the clean-d, clean-z, clean-ze, clean-a, clean-dfi and clean-dzgg checks compare the output’s BLAKE3 with MuPDF’s. Byte-identical output needs MuPDF’s encoders as well as its decisions:

  • Deflate is a port of zlib 1.3.2 (deflate), the copy MuPDF bundles, reduced to the one compress2 call MuPDF makes. zlib-rs, which nupdf decodes with, is a port of zlib-ng, whose match finder differs: its output differed from MuPDF’s at most levels. The port matched MuPDF’s zlib on 52 inputs (empty to several megabytes of text, binary, random and PDF data) at every level before any PDF was written with it. -e maps effort 1 to 100 onto levels 0 to 9 as MuPDF does, so efforts 1 to 11 store the data uncompressed.
  • 1-bit images are CCITT G4 encoded with a port of MuPDF’s encoder (fax_encode), when the data is exactly the image’s rows. MuPDF compares /Subtype with /Image by pointer, so an image whose /Subtype is a reference is deflated instead; the port keeps that.
  • Filter arrays are edited in place. Taking ASCIIHexDecode off for -z, or putting it on for -a, changes the filter array, and an array reached through a reference is shared: every later stream that uses it, and the array object when it is written, sees the change. MuPDF writes streams whose filters no longer match their data this way, and so does nupdf; the read-back check then refuses the output. An array left with one filter becomes that filter, and a /DecodeParms that is not an array becomes null. An empty /DecodeParms array fails the whole save, in both tools.
  • Lookups miss what MuPDF’s miss. A sorted dictionary whose last key is /Filter reads as unfiltered (D18’s lookup quirk), so -z compresses its data again and appends a second /Filter.
  • Brotli (-zz) is refused. MuPDF marks it experimental, and matching it would need a port of Google’s encoder.

mutool clean turns on a line per dictionary entry for -a and -d without -z; the write options MuPDF’s script interface takes do not, so the checks pass pretty to the oracle explicitly.

With -d after -gg, MuPDF decodes each stream through its renumbered dictionary; nupdf decodes it through the input’s, which leads to the same objects except where renumbering’s own quirks (D19) point a reference elsewhere.

On the standard tier the checks match MuPDF on every file except AES ones (random IVs, D18) and, where images are decoded, the files whose image decoding already differs in the images check (JBIG2 refinement and halftone regions, mostly). On bomb_giant.pdf, jbig2dec decodes a 100000 by 100000 page until MuPDF’s compression-bomb check fails the save; nupdf’s JBIG2 decoder refuses the page and writes the stream empty.

The read-back check compares decoded data when any stream option is given, and leaves /Filter and /DecodeParms out of the dictionary comparison, since changing them is the point. Data that went through the fax encoder is compared without the bits that pad each row to a byte: the encoder never reads them, so they come back white.

D21. Object streams are gathered as gather_to_objstms gathers them

2026-11-10

clean -Z packs objects into object streams and ends the file with an xref stream instead of a table, and the clean-Z, clean-Zg and clean-Zggz checks compare the output’s BLAKE3 with MuPDF’s. What gets packed, and where the save fails, follows MuPDF’s loop, not the PDF specification:

  • What is packed. Every object whose xref entry is in use with generation 0, in number order, 256 at most to a stream, printed compact with nothing between them. Left out are streams, the trailer’s /Root and /Info, the encryption dictionary, bare integers (they may be a /Length) and bare references. The test is the xref entry’s generation, which renumbering moves with the object, so after -gg an object that had generation 1 is still left out although it is written with 0.
  • Unused objects are packed too. At -g, an object nothing reaches still goes into an object stream; it is just listed as free, with its index as offset and generation.
  • Loading comes first. Gathering loads each entry before looking at it. An object that does not load fails the save, so -Z fails on files clean writes. So does a number no xref section defines, when MuPDF’s entry lookup falls back to the newest section: always for a file with one section, and below the oldest section’s size for a file with several. 22 standard files fail this way with -Z, in both tools.
  • Object streams from the input are written as ordinary streams, since only -Z keeps them. One that failed to load has a free entry from then on, so it is neither written nor packed.
  • The version becomes 1.5 when it is lower, and the catalog’s /Version, if it has one, is rewritten.
  • The xref stream is a new object after every other one. Its entries stop at the xref’s length after truncation, so when renumbering leaves nothing, the stream is not among its own entries.

An encrypted document can have its crypt filter dictionary packed into an object stream that needs it to be decrypted; MuPDF writes that, and the read-back check refuses it. Otherwise -Z output reads back: the check also compares the objects in object streams, leaves out the object streams and xref stream the writer adds, and ignores the catalog’s /Version.

On the standard tier the three checks match MuPDF on every file except AES ones (random IVs, D18) and issue20453.pdf, where MuPDF’s repair during the save differs from nupdf’s (D18).

D22. New encryption follows pdf_new_encrypt, with derived secrets instead of random ones

2026-11-10

clean -D removes the security handler and clean -E rc4-40|rc4-128|aes-128|aes-256 replaces it, with -O, -U and -P for the passwords and permissions, as mutool clean does. The new handler is a port of pdf_new_encrypt and create_encryption_dictionary: the same keys from the same passwords, the permissions masked to the bits MuPDF keeps, and an /Encrypt dictionary written directly into the trailer with its entries in MuPDF’s order.

  • -D is compared byte for byte (clean-D), and matches MuPDF on every standard file both tools write, the AES ones included, since nothing is encrypted.
  • -E cannot be. mutool clean keeps the /ID it reads, and because of that never hands it to the encryption step, which then makes a new /ID at random. The RC4 and AES-128 keys come from that /ID. AES-256 also draws its file key, its salts and four bytes of /Perms at random, and AES output needs random initialization vectors. nupdf replaces the /ID too, but derives every one of these values from the input (D18 does the same for IVs), so the same input and passwords always write the same bytes. The AES-256 file key is derived from the passwords as well as the content. The roundtrip-E40, roundtrip-E128, roundtrip-Eaes128 and roundtrip-Eaes256 checks instead have MuPDF read each side’s output back, with an owner password only, and compare the decrypted objects.
  • An empty stream under AES gets MuPDF’s /Length of an IV and a block but no data (D18). Reading it back runs into the bytes after it, so the stream does not survive. 278 standard files have one, and nupdf’s read-back check refuses them with -E aes-128 or aes-256; --no-verify writes MuPDF’s output.
  • Streams under a crypt filter of their own (/Filter /Crypt, as embedded files and some metadata use) are copied with their data still encrypted under the old key, then encrypted again under the new one, as in MuPDF. They no longer decrypt; the check refuses them (10 standard files).

Passwords are cut at 127 bytes, as MuPDF’s write options store them, and converted to PDFDocEncoding for revisions 2 to 4.

D23. Edits are an overlay on the document, and page selection follows pdf_rearrange_pages

2026-11-10

MuPDF edits a document by changing its objects in memory. nupdf’s Document keeps the parsed file as it was and records edits beside it: objects replaced, changed or added, a new trailer, the data of new streams. Every lookup consults the edits first, so page lookup, inheritance, name trees, the writer and the read-back check all see the edited document without knowing about edits. The page map is built again after each edit, as MuPDF drops its own. The /Count rewrite MuPDF makes when a page tree is short becomes an edit too.

MuPDF moves an edited object’s xref entry into an incremental section, and the writer uses that entry. Two cases differ, and both show in the output. An object replaced with pdf_update_object gets a fresh entry, so it is written with generation 0. One changed in place keeps its generation, except one from an object stream, whose generation becomes 0. The overlay records which happened (update_object, alter_object).

clean --pages is mutool clean’s page arguments: pdf_check_document first, so that a repair cannot lose the edits, then pdf_rearrange_pages with the structure tree dropped, then the save. Matching its output needs its in-place changes, quirks included:

  • The catalog is replaced by one with only /Type, /Pages, /Outlines, /OCProperties and the named destinations that lead to a kept page; /AcroForm, /Metadata, /Lang and the rest are gone. A direct /Pages or /Outlines is shared between the old catalog and the new one, so changes to it show in both.
  • Name tree keys may be strings or names. A link or outline item whose /Dest is a name, not a string or an array, counts as leading nowhere and is removed.
  • Form fields are found from widgets whose /Subtype is a direct /Widget, then trimmed from the top of their /Parent chain. In a malformed file whose widget has the page tree as /Parent, that empties /Kids, in both tools.
  • A page or /Pages that is not a dictionary fails the command, as pdf_dict_put does.

The harness’s clean-pages-reverse, clean-pages-first and clean-pages-rest checks compare the output’s BLAKE3 with MuPDF’s for N-1, 1 and 2-N. MuPDF’s script interface has no pdf_check_document, so the oracle loads every object itself before rearranging, as mutool clean does. On the standard tier the checks match on every file but 25 AES ones. Both tools fail on 66 to 71 more files than clean does, whose page trees cannot be counted or walked.

D24. Editing commands write as clean does

2026-11-10

meta, pages with an edit, split, encrypt and decrypt have no mutool command of their own to match; mutool clean comes closest, and its page arguments already do what pages --select and split need. So each command is clean with MuPDF’s defaults and one edit before the save: pdf_check_document first, so that a repair cannot lose the edit, then the edit on the overlay (D23), then the save and the read-back check. The output is what mutool clean would write had the same edit been made through MuPDF’s API.

  • meta --set KEY=VALUE is pdf_set_metadata: an info: prefix is dropped, an /Info that is not a dictionary is replaced by a new object, and the value becomes a text string as pdf_new_text_string makes it: ASCII as is, anything else UTF-16BE after a byte order mark. Dates are stored as the text given, which is where MuPDF’s parsed date ends up too, since it writes the text over it. --strip removes the trailer’s /Info and the catalog’s /Metadata, before any --set.
  • pages --select and --delete are clean --pages, with --delete keeping the pages it does not name, in order. --rotate adds a multiple of 90 to each page’s inherited /Rotate, read as pdf_to_int reads it, and puts the result, from 0 to 359, on the page itself, after any selection. A page that is a direct dictionary is changed where it sits, in its parent’s /Kids, as MuPDF changes it: nupdf finds it as its page lookup did, through the page map’s walk or the slow descent.
  • split writes each part from a fresh copy of the input, as clean --pages would. Its parts are named after the input, numbered from 1 and zero-padded to the same width.
  • encrypt and decrypt are clean -E and clean -D.

pdf_clean_file refuses a document that the empty password does not open, unless -p gives one. MuPDF still decrypts such a document with whatever key the failed attempt left, which can even be the right one: an empty owner password authenticates, then is discarded because the user password is not empty. Every command that writes refuses it too, with exit 3 and input.needs_password, and the harness’s clean.js oracle refuses it as mutool clean does. That is 45 standard files and two smoke fixtures, which move from matching to both tools failing in every write check. They are the documents opened without their password that D18’s read-back check refused as lossy output; they now fail before the save.

The meta-set, meta-strip, pages-rotate and pages-select-rotate checks make the same edit through MuPDF’s script interface (setMetaData, deleting the entries, getInheritable and put) after loading every object, then save as clean does, and compare the output’s BLAKE3. A trailer that is an xref stream’s dictionary belongs to that object, so MuPDF’s first edit to it moves the object into a new incremental section, whose trailer is copied before the edit; the copy is what it writes. A lone delete("Info") is lost that way, on 11% of the standard tier. nupdf’s --strip removes /Info either way, and the oracle deletes twice, the second time from the copy. On the standard tier each check matches on every file but the 20 AES ones clean differs on.

D25. Grafting follows pdf_graft_mapped_object, and a new document is pdf_create_document’s

2026-11-10

mutool merge makes a new document with pdf_create_document, then, for each input, a graft map of its own and pdf_graft_mapped_page for each page of its range. nupdf does the same, and what MuPDF does along the way shows in the output:

  • A new document is two objects: a catalog, and a page tree with no pages. As in MuPDF, its /Info is a dictionary inside the catalog, not in the trailer, naming the producer: nupdf where MuPDF names itself. There is no /ID. nupdf builds it as a small file opened like any other, so the overlay (D23) holds everything added later.
  • Copying an object gives an indirect object its new number before what it holds is copied, so numbers follow the order references are first met, depth first. A stream’s data is copied raw (decrypted, still encoded) and its /Length set to match, which leaves an indirect /Length behind as an object nothing uses. A map copies each source object once, so pages from one input share what they shared; a second input, or the same file again, gets a new map and new copies.
  • Null is no object. MuPDF’s null object is the null pointer, so an object that cannot be loaded, or that is free, resolves to the same thing as one that resolves to null, and pdf_update_object given it deletes the new object: the output has a free entry where the reference points, of generation 1. The same identity means pdf_dict_get never returns a stored null, so page lookups, inheritance and the page edits treat a null value as absent and look further up. pdf_delete_object is in the overlay too: the entry becomes free with the next generation when the file had the object, and goes back to the file’s own entry when the file had it free or never had it.
  • A failure partway keeps what was copied. A reference past the source’s xref fails the page, and mutool merge reports the input and moves on to the next, so the pages merged before it stay, and so do the objects the failed page had copied, some of them taken but never filled, which are free. nupdf reports the input’s error in its entry and a merge.input_failed warning, and fails the command only when no input opens, where MuPDF would write an empty document. A locked input is refused, as every write refuses one (D24).
  • Inserting a page is pdf_insert_page: into its parent’s /Kids where the slow lookup (pdf_lookup_page_loc) finds the neighbouring page, with /Parent set and /Count raised on the parent and every node above it (pdf_walk_parent, whose cycle check lets a few counts change before it notices). Inserting at page 0 of a document with more than one page starts page labels, as MuPDF does, flattening the label tree into one /Nums array first; later labels shift either way.

The recursion in pdf_graft_mapped_object would follow a chain of references as deep as the file makes it; nupdf’s copy keeps its own stack, in the same order, so a long chain cannot exhaust the thread’s.

The merge, merge-twice and merge-reverse checks compare the output’s BLAKE3 with MuPDF’s for each file merged whole, twice, and in reverse. The oracle grafts through MuPDF’s script interface as mutool merge does, saving as clean saves, with nupdf as the producer; it matches real mutool merge output, apart from the comment line and the producer, on every smoke fixture without links or outlines.

2026-11-10

mutool merge copies two things besides pages, and both pass through the URIs MuPDF’s link layer speaks: #page=3&zoom=150,72,720 for a place in the document, #nameddest=intro for a named destination, file:other.pdf#page=2 for another file, anything else for the web. nupdf ports that layer (link, from pdf-link.c) rather than copying the objects, because what MuPDF writes is what the URIs say, not what the source held:

  • Links. A page’s Link annotations are read as pdf_load_link_annots reads them: the rectangle through the page’s transform (geometry, pdf_page_obj_transform), the target from /Dest, /A, or the /AA mouse-up or mouse-down action. Only external ones are copied, fz_is_external_link being a scheme of three or more characters; a relative URI action becomes external against the catalog’s /URI /Base, or file://. Each becomes a new annotation from pdf_create_link: the rectangle back through the destination page’s inverse transform, a solid border of width 0, and an action from pdf_new_action_from_link, which turns file: URIs into GoToR actions with a new file specification object. Links to pages within the document are not copied, as in MuPDF.
  • Outlines. The source’s outline is walked with MuPDF’s iterator (outline, from pdf-outline.c), which first repairs /Parent, /Prev and /Last pointers that disagree with the /First and /Next chains. An item whose target resolves to a merged page is inserted with its ancestors, #page= URIs renumbered to the merged document, and a backward range counted from its low end, as position_in_range counts it. The quirks show in the output and are kept: every item copied in one run gets the URI of the last, a new node’s open state is ignored and inserting below it closes it (its /Count goes from 0 to -1), and the root’s /Count goes negative the same way.
  • Floats. A coordinate passes through %g, which in MuPDF’s printf is the shortest form that reads back exactly, so the trip through a URI loses nothing; the transforms are single precision, as MuPDF’s are.

The merge checks’ oracle mirrors pdfmerge.c through MuPDF’s script interface, links and outline included. On the smoke fixtures with links and outlines it matches real mutool merge output apart from the comment line and the producer. merge-links.pdf covers URI, relative, Launch and GoToR links on a rotated and cropped page, and outline items by explicit and named destination, action and none.

D27. Incremental saves are MuPDF’s, touches included

2026-11-10

--incremental on meta and pages appends the edit to the file as a new revision, as pdf_save_document with incremental does: the file’s bytes as they were, then a blank line and the comment naming the writer, the objects in MuPDF’s incremental section in number order, an xref for those entries alone, and a trailer pointing back with /Prev. MuPDF writes the xref in the style of the last one it read while loading, which for a file of several revisions is the oldest: a table, with the trailer copied from the newest and its /Size and /Prev set and /XRefStm removed, or an xref stream, a new object numbered after the others whose /Index lists the runs of entries. A free entry in the increment gets generation 65535, so it is never reused. Nothing changed leaves the file as it was. The /ID is kept, as in every nupdf write.

What goes into the increment is whatever MuPDF’s edits touch, not only what they change. pdf_dict_del moves its object into the incremental section even when the key is not there, so meta --strip writes the catalog either way. And a trailer that is an xref stream’s dictionary belongs to that stream, so the first edit to it moves the stream object into the section, where the writer skips it, leaving a free entry in place of the old xref stream (D24’s lost delete is the same effect). nupdf’s edits touch the same objects; a full save, which writes everything, never shows the difference.

As in MuPDF, a new document, a repaired file, garbage collection and a change of encryption are refused, with exit 2 and usage.incremental; nupdf also refuses object streams. The meta-set-incremental, meta-strip-incremental and pages-rotate-incremental checks compare the output’s BLAKE3 with MuPDF’s incremental save of the same edit, the comment line in the increment swapped first. On the standard tier they match on every file but AES ones, whose new strings get MuPDF’s random IVs (D18); both tools refuse the 211 repaired files and the 45 locked ones, and fail on the 19 that do not open.

D28. Output is validated against MuPDF’s, not against perfection

2026-11-10

qpdf --check and the Arlington PDF Model judge a file on its own. Most of what they find in nupdf’s output was in the input, and MuPDF’s output, which nupdf matches byte for byte almost everywhere, has the same findings. So the validation checks are differential like the rest: each side writes the document, the validator reads each output, and nupdf’s output passes when the validator says the same of it as of MuPDF’s. An absolute pass would fail nupdf on every damaged input MuPDF also writes back damaged.

  • qpdf. The qpdf-clean, qpdf-clean-Z, qpdf-merge and qpdf-meta-incremental checks compare qpdf’s exit status (0 clean, 2 errors, 3 warnings) and its messages, with the file’s path taken out. qpdf is a test dependency, installed in CI, never linked.
  • Arlington. nupdf vendors the definitions for the dictionaries it writes or edits: trailer and xref stream, catalog, page tree and pages, link annotations with their actions, border styles and file specifications, outline and outline items, and the document information. It applies the rules that need no predicates: required keys (inheritable ones looked up the page tree), value types with fn: version wrappers looked through, fixed name lists, and the direct and indirect requirements. A key whose value is null, or a reference to nothing, counts as absent, as ISO 32000 7.3.7 says. Keys the model does not list are not reported, since MuPDF writes some (a new document’s /Info sits in its catalog, D25). The arlington-clean, arlington-merge and arlington-meta-incremental checks compare the violations. cargo xtask arlington <files> reports them for any file.

D29. Content streams are checked operator by operator, through MuPDF’s processor

2026-11-10

M4’s exit is measured on mutool trace, which needs the whole interpreter: fonts, colorspaces, functions, shadings and images loaded, then run against a device. The content parser comes first and is checked on its own, against what MuPDF’s interpreter hands a processor (PDFPage.process in the script interface): each operator with its operands, after the lexing, the operand stack, the text-array rules, error recovery and inline image skipping.

  • The parse alone. The operators and operators-warnings checks take each page’s resources and annotations away first (--no-resources, and the oracle’s bare). Every resource lookup then fails the same way on both sides, so the result depends on the content streams alone, not on loaders that come with later items. With the resources kept, nupdf operators already looks them up, but hands fonts, patterns, shadings and images over unloaded, and does not evaluate optional content yet.
  • State as MuPDF keeps it. The operand stack holds 32 numbers, one name (255 bytes), one short string in a 256-byte buffer, and one object. Missing operands read as 0, extras are ignored, and the buffer is never cleared, which matters to whatever reads a short string as a C string (below).
  • Inline images. MuPDF decodes the image to find where its data ends, then looks for EI. nupdf runs the image’s filters over the content through a leech that counts what it hands out, as pdf_load_compressed_inline_image does. The decoders take what they read: LZW, ASCIIHex, ASCII85 and RunLength stop at the bytes asked for, Flate at the end of its buffer or its stream, and DCT and CCITT where their decoders stop reading. The colorspace decides how many bytes the image has, so colorspace definitions load here (colorspace), without tint transforms or ICC profile parsing yet.
  • Contents arrays. The parts are joined with a space after each, a part that does not open is skipped with a warning, and one whose decoding fails ends the contents there, as fz_open_concat does. Parts are decoded a byte at a time, as the lexer reads them, so a decoder that fails loses only the byte it was asked for. That made LZW honor the reader’s size hint, as next_lzwd does.
  • The oracle’s quirks. The script glue has bugs that the comparison works around rather than hides. It passes " the wrong arguments, so " is left out on both sides. For an inline image without a /CS name it pushes a null string, which crashes the script engine, so BI is not reported on both sides; what follows the image shows where it ended. It hides the ExtGState’s line, font and flatness entries inside a gs, but not its dash. And it pushes short ASCII text as a C string, which runs on into what longer strings left in MuPDF’s buffer and is read by mujs as UTF-8. nupdf’s library reports the exact operand and also its C-string view, and only the recorder that writes the oracle’s format reproduces the rest.
  • Warnings. operators-warnings compares the distinct warnings and reported errors, the content’s syntax errors among them, which made diagnostics keep every distinct message past their cap of 1,000 entries (up to 10,000), and made decoders print a bad byte as MuPDF’s %c does.

D30. Resources load where MuPDF’s interpreter loads them

2026-11-11

pdf_process_keyword does not only dispatch operators: it loads the colorspace CS names, the shading sh names and a shading pattern’s shading, and throws when one does not load, which counts as a syntax error and drops the operator. So the loaders come before the interpreter, and are checked through the same processor oracle with the page’s resources kept (operators-resources).

  • Functions are a port of pdf-function.c, evaluation included: shadings sample their functions when they load, 256 times along an axis and on a 257 by 257 grid for a function-based shading. Arithmetic is single precision with MuPDF’s guards, and integer arithmetic in calculator functions wraps as C’s does in practice. Function evaluation shows only in rendering, so it is checked fully in M7.
  • Colorspaces load their tint transforms. An ICC profile is not parsed: its header names the component count, and MuPDF’s checks with lcms on a broken profile (which warn and fall back to /Alternate or /N) are not reproduced. (D31 reproduces the header checks.)
  • Shadings are kept per object number for one run of a page, as MuPDF’s store keeps them, so that a function-based shading used many times is sampled once.
  • What waits. Fonts load with the font item, so Tf still hands over the font dictionary, and the warnings of a run with resources are not compared yet: font loading warns, and so do the transparency scans pdf_load_page makes of the resources. Optional content waits for the interpreter: MuPDF’s processor for scripts has no usage, so it never evaluates it.

D31. The run processor is checked against MuPDF’s script device, text left out until fonts

2026-11-10

The interpreter item is pdf-op-run.c ported whole, and its check runs each page’s contents into a device on both sides: MuPDF’s script device (PDFPage.runPageContents, oracle/js/trace.js) and nupdf’s trace device (nupdf trace). Each device call is printed as an array, and the trace check compares them page by page.

  • What a call shows. Paths as their walked segments, matrices, colors with their colorspace’s type and component count, stroke states, color parameters, images by shape, colorspace, decode array, color key and mask, shadings by their bounds under the call’s matrix, groups, masks, layers, structure and metatext. A colorspace is not named: an ICC space’s name comes from its profile’s description tag.
  • The glue’s quirks are followed. The glue has no script object for MuPDF’s device Lab space, so the script sees undefined for it (null in the trace). Its beginTile returns from inside its exception frame, which breaks the script engine after a few tiles, so the oracle leaves the tile callbacks out; MuPDF then draws a tile’s content once, and neither side prints tiles.
  • Text is left out on both sides (without_text; superseded by D32, which compares text): the calls that show text, the clips text opens (their content stays), and the groups and masks that hold nothing once text is gone. Without fonts nupdf draws no glyphs, but it tracks what text changes elsewhere: shown text flushes pending layers and structure where MuPDF flushes it, glyphs shown in a clipping mode make a text clip at ET that ends with its gstate, and a cm with such a clip pending fails the page as MuPDF’s does. What remains needs fonts: Type 3 glyphs MuPDF draws through the device, and text filled with a pattern or a shading (its clip, and the pattern’s content or the shading, are drawn for it).
  • Numbers agree within 1e-4 of their size. The oracle build fuses multiply-adds, so a matrix or a box can differ in its last bits.
  • What is emulated rather than kept on the document. MuPDF writes the parents its structure tree check repairs into the document; nupdf keeps them with the store, per document. MuPDF memoizes the transparency scan of a resource dictionary; nupdf computes it afresh, which differs only for cycles through resources.
  • Limits. The graphics state stack stops where MuPDF’s does (2048 levels, “too many nested graphics states”), which bounds how deep forms and patterns nest; the CLI runs on a 1 GiB stack (touched only as used) so that this depth fits. MuPDF can fail earlier, at about 60 nested forms, when its 256-frame exception stack overflows; that depth counts frames of its own and of the script glue, and is not reproduced. A pattern fill that would draw more than 2^20 cells fails (“too many pattern cells”), where MuPDF would draw them all.
  • ICC profiles and JPEG 2000 headers are judged as lcms and openjpeg read them: lcms’s header checks (signature, version, device class, tag directory) and channel counts, with MuPDF’s family checks against /Alternate; and openjpeg’s header read (the SIZ segment, the JP2 colour specification box and embedded profile), with MuPDF’s choice of colorspace from it and the dictionary’s, and 8 bits per component.

D32. Fonts load as MuPDF loads them through FreeType, and text is in the trace

2026-11-11

The fonts item is pdf-font.c, pdf-type3.c, pdf-unicode.c and pdf-metrics.c ported on top of a port of the parts of FreeType MuPDF uses. The trace check now compares text: each text call carries its spans as the script glue walks them (the font’s name, the matrix without its translation, writing mode, bidi level, markup direction and language) and each glyph’s id, Unicode and position. The harness’s text filter is gone.

  • Font programs open as FreeType opens them. TrueType and OpenType (font::sfnt: the table directory, head, maxp, hhea, hmtx, OS/2, post names, and each cmap subtable validated at FreeType’s default level), CFF (font::cff: charsets, encodings, CID-keyed fonts and their FDSelect, widths from charstrings) and Type 1 (font::type1: PFA and PFB, eexec, the private dictionary and charstrings, .notdef moved to glyph 0). The drivers are tried in MuPDF’s order, and a failure carries FreeType’s error, so a font that FreeType refuses (“FT_New_Memory_Face(%s): broken table”) falls back as it does in MuPDF. Charmaps, the default Unicode charmap, a Unicode charmap synthesized from glyph names, and MuPDF’s ft_char_index and ft_name_index fallbacks follow FreeType and fitz.
  • Outlines are not read yet (until D34). A glyph’s box is taken to be its font’s box. Boxes only shape what is drawn around text (a soft mask’s or a knockout group’s bounds, the area a pattern covers), and no standard-tier trace differs because of it. Outlines come with rendering (M7), and with them the width table MuPDF builds for stretching substitute glyphs.
  • The standard 14 fonts and the substitutes are the URW fonts MuPDF bundles, vendored unmodified (NOTICE). There are no system fonts, as in the oracle.
  • No CJK fonts (until D34, which carries their metrics). MuPDF’s default build bundles Source Han Serif (a 25 MB collection). nupdf does not, and behaves as MuPDF built with NO_CJK: a CJK substitute fails (“cannot find builtin CJK font”) and the text takes the last-resort font. This is what remains of the trace gap. Carrying the font, or loading it when asked, is left for later.
  • Data tables are generated from the pinned oracle by cargo xtask tables: the Adobe Glyph List and the named encodings, the predefined CMaps (as a compact binary, linked through their /UseCMap chains on first use), FreeType’s PostScript name tables and CFF’s standard encodings, and UCDN’s bidi classes.
  • MuPDF’s quirks are kept. A span’s language comes only from the innermost marked content’s own /Lang: MuPDF means to fall back to the structure element and to outer marked content, but pdf_dict_get_string never returns NULL. Span fields are compared and kept as MuPDF’s bit-fields hold them. cid_to_ucs is MuPDF’s unsigned short. Type 3 glyphs are run once when their font loads, for the device flags their operators leave; a glyph that inherits the graphics state, or a colored glyph filled with a pattern, is drawn through the device, and a font that reaches itself while loading or drawing fails (“recursive type3 font”). A Type 3 glyph’s box is its d1 box, or the font’s when that is not to be trusted, where MuPDF would measure the glyph with a bounding-box device (D34 measures it).
  • Limits. Remapping a ToUnicode CMap through a composite font’s encoding stops after 2^24 codes (“ToUnicode remap too large”); MuPDF has no bound, and a range over all four-byte codes would keep it busy for minutes.

D33. Fonts and images are listed as mutool info lists them, and extracted as mutool extract writes them

2026-11-11

The commands item adds fonts and images. Their lists, the files they write and the pixmaps images --digests decodes are each checked against MuPDF (fonts, font-files, image-list, image-pixmaps, oracle/js/inventory.js).

  • The lists follow mutool info’s walk (gatherresourceinfo with everything shown): each page’s own /Resources, not inherited ones, through fonts, XObjects and patterns; each object visited once per page; each font or image dictionary listed once, at the first page it is found on, deduplicated with pdf_objcmp. A page that cannot be found is reported and the walk goes on, where mutool info would stop. The oracle is a script, so it walks with MuPDF’s objects but not with MuPDF’s own walk; the walk is short enough to read against pdfinfo.c line by line.
  • fonts also says what each font loads as (load), from the font loader of D32, with its Type 3 fonts’ resources left to their own. MuPDF’s scripts cannot see this, so the check leaves it out.
  • Files are written as mutool extract writes them: every font descriptor’s program by its key (FontFile as .pfa, FontFile2 as .ttf, FontFile3 by its /Subtype), every image XObject as JPEG data where its last filter is DCTDecode and it has no decode array or color key, otherwise decoded, as PNG for gray and RGB and PAM for the rest. The PNG has no iCCP chunk: nupdf does not keep an ICC space’s profile. The checks compare written files by hash, on both sides.
  • Pixmaps are decoded as fz_get_pixmap_from_image decodes them at full size: the samples read up to the rows the image needs (so no compression-bomb guard applies, as in MuPDF’s image read), JPEG frame headers patched to the image’s height, samples unpacked to 8 bits, image masks inverted, color keys, decode arrays, indexed images in their base space, and /Matte colors unblended with the soft mask. --digests gives the MD5 of the samples, as MuPDF’s computeMD5 does; the script glue shows MuPDF’s device Lab space as null, and so does the report.
  • JPEG 2000 images are not decoded yet: they are listed and fail to decode with an unsupported error. Decoding them as openjpeg does is left for rendering (M7).
  • Both sides list before they decode. Finding pages can repair a document, which changes what later objects load as, so the oracle walks the pages before it extracts or decodes, as the commands do, and object counts are taken before anything loads.

D34. Structured text is MuPDF’s stext device, glyph boxes come from outlines, and CJK substitutes carry their metrics

2026-11-11

The first M5 item ports the stext device (stext) and adds nupdf stext. The stext check compares each page’s blocks, lines and characters with Page.toStructuredText walked by oracle/js/stext.js; stext-preserve and stext-raw do the same with MuPDF’s other options. A file matches when every page has the same blocks, lines and characters, each character’s font, flags and bidi level equal, its origin and size close, and its box overlapping MuPDF’s by an IoU of at least 0.98. The harness also reports the M5 measures: the pages whose text is MuPDF’s character for character, and the characters whose box overlaps MuPDF’s by 0.98 or more.

  • The device is stext-device.c: lines and blocks from the pen’s motion (spacing, baseline offset, paragraph indents, bullets), synthetic spaces, fake bold, ligature and presentation-form decomposition, whitespace folding, ActualText (prefix and suffix matching, text flushed at the pen, or over the bounds of what it covers, with Helvetica borrowed when no font has been seen), clipping by the device’s scissor and the media box, image blocks, and the closing pass (dehyphenation flags, boxes, right-to-left runs reversed). The container stack and its scissors are device.c’s, kept inside the device. An unbalanced call disables the device as MuPDF’s does: it stops taking text and images and is not closed. Only the page’s contents run, as fz_new_stext_page_from_page runs them; annotations are not part of it.
  • Options are MuPDF’s option strings, parsed into MuPDF’s flags: preserve-ligatures, preserve-whitespace, preserve-images, preserve-spans, inhibit-spaces, dehyphenate, clip (and mediabox-clip), clip-rect, use-cid-for-unknown-unicode, use-gid-for-unknown-unicode, accurate-bboxes, accurate-side-bearings, ignore-actualtext. Structure, vectors, styles, segmentation, tables and accurate ascenders are refused until the items that implement them. The defaults are MuPDF’s: clipping only.
  • Colors are not compared. A character’s color is converted to RGB as MuPDF converts it, which goes through its color management for CMYK and ICC spaces; the device converts gray, RGB and CMYK naively. The check leaves color out.
  • Glyph boxes come from outlines, as fz_bound_ft_glyph measures them: the control box of the glyph’s unhinted outline at one font unit per 26.6 unit, every point counted. font::outline ports FreeType’s TrueType loader (simple glyphs, composites with their transforms, offsets and point matching, the recursion and loop checks) and its Adobe charstring engine for CFF and Type 1 (stems and hint masks for their byte counts, flex, seac, the other subroutines, the arithmetic operators, the stack and nesting limits), as far as which points reach the outline; a charstring’s coordinates come out floored to whole units, as the engine’s shift does. A glyph that fails to load is bounded by a point, as in MuPDF. A substitute font’s glyphs are stretched to the PDF’s widths through the width table MuPDF builds (pdf_make_width_table). Boxes are measured once per glyph and kept with the font, in blocks of 256 as bbox_table is. Fake bold and fake italic substitutes, which the bundled fonts never need, and CFF inside an sfnt (only system fonts have it) are not measured; the latter keeps the font’s box.
  • A Type 3 glyph’s box is measured as fz_bound_t3_glyph measures it: the bbox device run over the glyph’s display list under the font matrix, with the list’s culling of empty content and of what an empty clip encloses.
  • CJK substitutes carry Source Han Serif’s metrics and nothing else: a font with its units per em, box, ascender and descender (its four faces share them), every character mapping to glyph 0, keeping no glyph boxes, as MuPDF keeps none for CJK fonts. Text extraction reads nothing else of the font, so non-embedded CJK text now extracts as MuPDF extracts it. The trace still differs there: its glyph ids are the real font’s.
  • A CID-keyed CFF font’s dictionaries keep their own units: a glyph is scaled from its font dictionary’s units per em to the top dictionary’s, as cff_slot_load scales it. Skewed font matrices are not applied.
  • Exit measures on the standard tier: 100% of pages have MuPDF’s text and 100% of characters have a box with IoU ≥ 0.98 (19,792 pages, 2,887,542 characters), with default options and with stext-preserve’s. stext-raw, whose boxes come from glyph outlines (accurate-bboxes), matches 99.8% of files: what is left is a Type 3 glyph that fails while its font loads (MuPDF keeps an infinite box), and a few fonts whose outlines nupdf does not yet measure as FreeType does. The speed measures come with the bench pairs item.

D35. extract gives text with content ids, in four formats and within budgets

2026-11-11

The output item adds nupdf extract. Its structure is the stext device’s (D34) with the options given; what it adds is nupdf’s own, except the plain text, which is MuPDF’s and is checked against it (text, text-dehyphenate, oracle/js/text.js).

  • Content ids are positions. p3 is page 3, p3.b2 its second block (text and image blocks counted together), p3.b2.l4 the block’s fourth line, p3.b2.l4.s1 the line’s first span, all counted from 1; p3.b2.l4.s1:5-12 names characters 5 to 12 of a span, from 0, the end excluded, for search hits and redaction. Ids depend on the document and the stext options, as block and line boundaries do.
  • Spans are runs of one style: font name, size, the character’s bold flag, the font’s italic style and color. A space the device adds takes its neighbours’ style, so it never starts a span. Colors are #rrggbb, with the alpha after when it is not opaque, converted as D34 says, so CMYK and ICC colors are approximate.
  • --fields picks levels and keys. Levels nest: blocks, lines, spans, then chars (each character and its quad); a level needs the one above. Text goes at the deepest level kept, so --fields text gives each page’s text alone and the default (everything but chars) gives each span’s. Ids are always given.
  • Budgets stop between blocks. --max-chars counts the characters of the blocks given and stops before the block that would pass it; --max-items counts blocks, or pages when blocks are not asked for; the first block is always given, so every call makes progress. A stopped report has "truncated": true and next_cursor, p3.b5@<doc_id>, which --cursor resumes from; a cursor for another document is a usage error. With --pages, a cursor resumes within those pages.
  • Plain text is MuPDF’s fz_print_stext_page_as_text, with mutool draw -F txt’s form feed and newline after each page: a line per line, an empty line after each block, and a line joined by dehyphenate written without its hyphen or break. Like Page.toStructuredText and unlike mutool draw, only the page’s contents are run, not its annotations.
  • Markdown is a reading of the blocks, not MuPDF’s. A block whose largest characters are 1.2, 1.4 or 1.8 times the page’s body size (the size most characters have, to half a point) is a heading of level 3, 2 or 1, and neighbouring heading blocks of one size are one heading; a line that starts with a bullet starts a list item, which the lines indented to its text continue; other lines join into a paragraph with spaces, or without one after a joined hyphen. Bold and italic runs that hold a letter or digit are marked. Markdown’s syntax characters are escaped. Each page starts with <!-- p3 -->, and image blocks are left out.
  • JSON lines give a nupdf.extract.page/v1 object per page, each with doc_id, then the report without pages. Text and Markdown go to stdout and the report to stderr, so a pipe gets only the text.

D36. Search is MuPDF’s, from its text transforms to its regular expressions

2026-11-11

The search item adds nupdf search, a port of fz_match_stext_page_cb. The checks search, search-icase and search-regexp compare each page’s hits, quad by quad, with StructuredText.search (oracle/js/search.js).

  • The haystack is the page flattened as MuPDF flattens it: lines joined by a space, blocks by a space, a soft hyphen or a line dehyphenate joined dropped with its line break (unless keep-hyphens), line and paragraph breaks kept as newlines with keep-lines and keep-paragraphs; each character knows where it came from, and added spaces come from nowhere.
  • Haystack and needle are transformed alike: compatibility decomposition, then the composition of marks after their sorting by combining class, full-width ASCII made ASCII, hyphens made -, spaces folded into one, and with the options upper case (fz_toupper) and non-spacing marks dropped. So é finds é and fi finds the ligature. The UCDN data (categories, combining classes, decompositions, compositions) and MuPDF’s case tables are generated from the oracle by cargo xtask tables into unicode.
  • Regular expressions are MuPDF’s (regexp.c, from MuJS): JavaScript’s syntax with captures, back-references, lookahead, classes and counted repeats, compiled per needle and run by backtracking with MuPDF’s limits (4096 levels, 2^20 steps for a search, 32K instructions); a pattern that does not compile fails each page with MuPDF’s message (regcomp failure: ...). Case-insensitive classes give MuPDF’s answers but find them from the case tables instead of trying every rune of each range, which MuPDF does and which can take minutes on [\W].
  • A hit’s quads are its characters’ quads, a character merged into the quad before it where it continues it (within half a size along the line and a tenth across, as MuPDF measures it). Hits made only of added spaces are skipped, as in MuPDF.
  • Hits carry content ids (D35): id for a hit within one span (p3.b2.l4.s1:5-12, from 0, the end excluded), otherwise begin and end, and their text.
  • An empty needle finds nothing. MuPDF returns no hits for an empty needle; one that becomes empty when transformed (only marks, with ignore-diacritics) would match everywhere without moving on in MuPDF, and finds nothing here.

D37. Pages run in parallel on request, with a report that does not depend on the thread count

2026-11-11

stext, extract and search take --threads N (parallel). Without it pages run one after another as MuPDF runs them, sharing one resource store; with it they run on N threads and the output is the same at every N, 1 included. The two can differ only in diagnostics.

  • Each thread keeps its own store (fonts, images, shadings, patterns) for the pages it takes, as a run without threads keeps one for all of them. The store holds layer state that is not Send, so it cannot be shared. What a store has cached does not change a page’s text: across the standard tier the pages from --threads equal the pages without it, for stext and for search.
  • Fonts are loaded once for all the threads. A font one thread has loaded is in a map the others look in before loading it themselves (Store::sharing_fonts); a thread takes its own copy of it, because every character holds its font, and eight threads counting the holders of one font cost more than the copy. Type 3 fonts are not shared: what their glyphs load depends on the page that first uses them. Two threads that load a font at once each use their own; the two are the same.
  • Nothing a page does on one thread waits on the others. Loaded objects are kept in slots set once (Cache), so reading one takes no lock; a thread keeps its finished pages until it is done; zlib inflaters are kept per thread and reset, since making one waits on the allocator’s lock when eight threads make them at once.
  • The work of turning a page into output runs on the threads too (parallel::map_pages): stext builds each page’s JSON there and search its hits.
  • The page tree is walked first, on the calling thread, so its warnings come in order. Then the threads take pages in turn and their results are put back in page order.
  • Each page’s diagnostics are kept apart while it runs (diag::capture) and reported once each, sorted by code and message. The document is shared, so a warning from loading an object goes to whichever page loads it first, and that changes with the thread count; sorted and deduplicated, the set does not. Repeats of a warning across pages are therefore reported once.
  • A repair during the run starts the run again. A page that makes the document repair itself changes what the pages before it would have read, and which pages ran before it depends on timing. So the file is opened afresh (Document::reopen, keeping an accepted password) and every page is done again, one after another, on the new document. A document made or changed in memory has no file to reopen; its pages stay as they ran.
  • extract with a budget works on 64 pages at a time, the same at every thread count, so --max-chars and --max-items stop it early without extracting the rest of a long document; without one it works on all the pages at once. Its threads are kept from one window to the next (parallel::with_workers), each with its store, and each page’s JSON is made on the thread that extracted it. If a window repairs the document, every store starts afresh for the next. stext works on all the pages asked for, and so does search with threads; without them it stops running pages once --max-items is reached, as before.
  • Workers run on 1 GB stacks, as the CLI’s main thread does: content streams nest about a thousand forms deep.

2026-11-12. With these, on the standard tier’s seven documents of 50 pages or more, 8 threads against 1 (stext-scaling, extract-scaling; 6 performance and 12 efficiency cores, with other work on the machine): stext 1.97× in geometric mean, extract 1.80×, from 1.92× and 1.63×; on freeculture.pdf (352 pages) 3.6× and 3.4×. The threads now run almost the instructions one thread runs (1% to 9% more, from up to 24%); what is left is that each instruction takes longer on eight cores than on one. Two of the seven documents take 4 to 6 ms in all, mostly starting the process, and one has 10,000 pages of a few objects each, whose shared objects the threads count at once.

D38. Reading order is an opt-in stext option of nupdf’s own, scored against labeled pages

2026-11-11

By default, blocks come in the order MuPDF gives them: the order the content stream draws them in, grouped by its stext device. Many files draw out of reading order (forms filled in field by field, sheets of notes drawn row by row), and an agent reading such text gets it scrambled. The reading-order stext option (order) reorders each list of blocks, the page’s and each structure’s, by a recursive XY-cut: the widest empty band across or down the area cuts it in two, read top before bottom or left before right, until no band separates the blocks, which then keep MuPDF’s order.

  • It is an option, not the default (D5): the default stays MuPDF’s, and the harness keeps comparing it.
  • It is scored against people, not MuPDF. corpus/reading-order/truth.json holds hand-labeled pages: for each, distinctive fragments of its text in the order a person reads them. cargo xtask reading-order scores both orders by the share of fragment pairs that come in the labeled order. The pages include ones MuPDF’s order already gets right (two-column papers, a one-column book page), so the option is shown not to make them worse, and ones it gets wrong (a sheet of notes in four columns, two forms). At introduction: MuPDF’s order 0.880, reading-order 0.995.
  • MuPDF does not know the option name and would reject it; nupdf accepts it alongside MuPDF’s options.
  • Columns drawn line by line across (2026-11-13). When the content stream draws a line of the left column, then one of the right, MuPDF’s device puts both columns into one block, and no reordering of blocks can separate them. The option now splits such a block at its gutter first, if the lines on each side are at least as wide as the gutter (columns of text, where a form’s short labels and far-off values stay together). Bands are weighed in what they would hold: a band across in lines of text, a band down in characters, taken as half a line, so paragraph breaks no longer outweigh a gutter. A heading of up to four blocks is taken off first when it reaches into the gutter and what remains divides into columns. The labeled pages still score 0.995; the agent eval’s two-column procedure page now reads column by column.

D39. Arithmetic is fused as MuPDF’s build fuses it

2026-11-11

The oracle is MuPDF built by clang at -O2, whose default -ffp-contract=on turns a * b + c * d into fma(a, b, c * d) and a * b - c * d into fma(a, b, -(c * d)) within an expression. Content streams written back (clean -s, redaction) print matrices and text moves at the shortest round-trip digits, so a last-bit difference changes the bytes. geometry (matrix concatenation, translation, point and vector transforms), the text matrix updates in run and the sanitize filter’s text moves use mul_add in the same places, which is what disassembling MuPDF’s code shows clang does. A MuPDF built without contraction (gcc on x86-64 without -mfma) would round differently; the harness’s oracle is the build in target/oracle.

D40. Redaction is MuPDF’s pdf_redact_page, given quads, then nupdf’s own scrub and check

2026-11-11

redact finds its targets as quads on each page (text and patterns with MuPDF’s search over the stext with clipping off and hidden layers run, regions, content ids, search reports), converts them to PDF space as pdf_set_annot_quad_points would, and redacts each page as pdf_redact_page does with those quads in place of Redact annotations: the sanitize filter (content_filter) drops glyphs whose box, shrunk by a tenth, meets an area, splits TJ arrays around them, gives each use of a form its own copy, removes line art an area covers (MuPDF’s default keeps it; the contract says vector content goes), and blanks image pixels; links and FreeText annotations over the areas go. The contract asks for more than MuPDF does, so after it:

  • Every annotation and widget over an area goes, whatever its type, with its popup, and a widget leaves the form’s fields.
  • Marked-content properties lose an /ActualText, /Alt or /E that holds a target, besides MuPDF’s edits to the structure elements its MCIDs name.
  • The document is scrubbed (scrub): metadata entries and XMP packets that hold a target are removed, as are structure /Alt, /ActualText and /E, JavaScript, named destinations and attachments; outline titles, annotation and field text, URIs, page labels and layer names have the matches cut out; redacted pages lose their thumbnails. The output is a full rewrite with unreferenced objects collected, so one revision remains.
  • Pixel redaction writes 8-bit samples as pdf_add_image does for a pixmap (a bit each when they are black and white), deflated by the writer; a JPEG image comes back as Flate, not DCT. Image masks and inline images are blanked as MuPDF blanks them, but an inline image mask is written a bit a sample (MuPDF writes a byte each, which reads back as another mask), and an inline image whose data cannot be written inline again (JBIG2, JPX) or whose colorspace BI names by resource is removed whole.
  • verify is independent of how the output was made (verify): page text with clipping off and hidden layers run, every string of every object (unreachable ones counting as history), text streams, marked-content properties and the strings appearance streams show, the pixels of images under the redacted areas, thumbnails, and the revision count. redact runs it on the output before writing and exits 4 without writing when anything is found.

Black boxes are drawn only with --boxes (MuPDF’s default draws them). Parity with MuPDF is checked on what the redaction leaves (redact-text: every run of digits redacted, each page’s text compared as MuPDF reads both outputs); the adversarial corpus (corpus/redaction, cargo xtask redaction) and the cases pdf-redaction-benchmarks generates (--benchmarks, its generator run in isolation at a pinned time) check that nothing leaks, judged by nupdf verify, MuPDF and pdftotext. --no-verify writes the output anyway, for the parity check: MuPDF’s redaction leaves what the check catches.

D41. The rasterizer is a port of MuPDF’s draw code; color management is moxcms made to act like lcms2

2026-11-11

The plan left the rasterizer open: vello_cpu, tiny-skia, or a port of draw-edge/draw-paint, to be chosen by measured pixel parity and speed. The exit asks for 98% of pages within a pixel threshold of mutool draw, and anti-aliasing alone puts a general-purpose rasterizer outside that on most text: MuPDF covers paths with a 17×15 subsample grid, draws glyphs with FreeType’s exact-area rasterizer at quarter-pixel offsets, scales images with its own filter, and paints with its own 8-bit rounding. hayro, on vello_cpu, shows the gap: on pdf.js’s transparency and knockout tests it is 20% to 60% of pixels away from MuPDF where the port matches it exactly (cargo xtask triage). So draw ports MuPDF: the edge list and scan conversion, path flattening and stroking, the span, glyph and image painters with their arithmetic, FreeType’s smooth rasterizer for glyphs (draw::grays), subsampling, the scaler and the affine painter, shadings as triangles, groups, soft masks, blend modes and tiles. The draw device keeps MuPDF’s state stack.

  • The threshold: a pixel differs when a channel is off by more than 8 (of 255); a page is within the threshold with at most 0.5% of its pixels differing. Sizes and per-page errors must agree. The render-72, -150 and their -icc checks render page contents without annotations (Page.runPageContents, render --no-annots); render-annots-* render whole pages as mutool draw does (D52).
  • Color management is on by default, as in mutool draw (D5). MuPDF’s own profiles (gray, sRGB, SWOP CMYK, Lab) are vendored and used through moxcms, a pure-Rust CMS. moxcms has no black point compensation, which lcms2 applies for MuPDF’s default intent; cms computes it as lcms2 does (cmsDetectBlackPoint: a perceptual round trip of black for an output CMYK profile, the darkest colorant otherwise; ComputeBlackPointCompensation in XYZ) around moxcms’s transforms into a linear sRGB. On SWOP CMYK swatches the result is within 1 of MuPDF’s. --no-icc is MuPDF’s -N: the device formulas.
  • Transparency is MuPDF’s: groups isolated or not (a non-isolated group paints over a copy of its backdrop and keeps its group alpha), knockout groups (each object over a copy of the group’s backdrop, with a shape, knocked back), soft masks by luminosity or alpha with transfer functions, the separable and non-separable blend modes with MuPDF’s byte arithmetic, including its non-isolated “uncompositing”.
  • Known differences: stroked text (render modes 1 and 2) is drawn by stroking the outline as a path where MuPDF uses FreeType’s stroker; colored Type 3 glyphs paint as masks; large glyphs (over 256 pixels) are filled unhinted where MuPDF lets FreeType hint them at 1024 ppem; tiles drawn inside non-isolated or knockout groups do not carry group alpha or shape; JPEG 2000 decodes through hayro-jpeg2000 rather than OpenJPEG; JBIG2 files on which jbig2dec gives up decode in full; images are decoded whole where MuPDF may decode a subarea, and JPEG is not decoded at reduced scale. Overprint is not simulated: MuPDF simulates it only for subtractive destinations, and render draws RGB.
  • Parallelism: pages render on --threads threads; a page of a megapixel or more with threads to spare is recorded once as a display list (draw::list, fz_display_list) and played into horizontal bands on separate threads. Every band gives the same pixels as the page drawn whole, which a test checks.

At introduction, on the standard tier: with color management on, 98.98% of 19,790 pages within the threshold at 72 dpi and 98.40% of 9,790 at 150 dpi; with it off on both sides, 99.30% and 99.03%. Against mutool draw -N at 72 dpi on a 200-file sample: one thread, geometric mean 1.25× MuPDF’s speed; eight threads, 1.28× (the oracle is built without threads, so MuPDF runs on one).

D42. The MCP server runs the CLI’s commands in-process, one thread per call

2026-11-12

nupdf mcp serves the commands as tools of a Model Context Protocol server, JSON-RPC 2.0 over stdin and stdout, one message a line (protocol revisions 2025-06-18, 2025-03-26 and 2024-11-05).

  • The tools are the CLI’s commands, read from its definition. Each command but mcp is a tool whose input schema is built from the command’s clap arguments: a flag is a boolean, a repeatable flag (clean -g) a count, an option with fixed values an enum, a list an array, anything else a string or a number, with the CLI’s help as the description and its default. The global options come with every tool except --pretty. A call is turned back into a command line, options in the order the command defines them and positional values after --, and parsed by the CLI’s own parser, so a tool takes exactly what the command takes and stays in step with it.
  • A call’s report is the CLI’s, byte for byte. The call runs run, the CLI’s dispatcher, on a thread of its own (on a 1 GB stack, as the CLI’s), with what the command writes to stdout and stderr kept for that thread. The result’s text is stdout, then stderr when there is any (the report of extract --format text); structuredContent is the report when the report is all the call gave; text and Markdown output has none, since clients show a model the structured content in place of the text (the agent eval’s pilot found agents seeing only extract --format text’s report). A failure is a tool error carrying nupdf’s error report. Calls run at once, each on its thread.
  • Files are read and hashed once; documents are parsed for each call. The server keeps each file’s bytes and doc_id (up to 1 GB, the least recently used going first), keyed by its path, size and modification time, so a changed file is read again. It does not keep parsed documents: a document reports a warning the first time an object loads, so a document reused from an earlier call would leave out of a report warnings the CLI gives. Parsing the xref again is a small part of a call; reading and hashing the file is what it saves.
  • A call stops at its time or when cancelled, where its work next checks. Each call has an Interrupt (nupdf::interrupt), a flag and a deadline (--call-timeout, 300 s by default, or the call’s timeout if shorter), which the threads it starts carry too. Content streams check it at every operator and page threads before every page, so a long call stops within a few operators of the deadline or of notifications/cancelled. Interrupted work breaks off as a damaged content stream would, so nothing it made is kept: its output goes nowhere, files it would write are not written (clean and the other writers check before the output is renamed into place), and a call out of time fails with exit 5’s limit.timeout; a cancelled call gets no answer, as the protocol asks. The CLI takes the same --timeout. When stdin closes, the calls still running finish, each within its time, and are answered.
  • Output has a ceiling. A report larger than --max-output-bytes (8 MB by default) is a limit.output error that points to the budgets (pages, fields, max_chars, max_items), rather than a message the client may not take.

D43. batch runs a command on each file in-process, writing a line per file in the order given

2026-11-12

nupdf batch <inputs>... -- <command> [args] runs nupdf <command> <file> [args] for each file, as the MCP server runs a call (D42): in-process, on a thread of its own, with what it writes kept.

  • Inputs are files, directories (their *.pdf, any case, at any depth) and glob patterns, each file once, in name order within an input and the inputs in the order given. A pattern that matches nothing is a usage error. A command whose first argument is not a file (merge, batch, mcp) is refused.
  • Files run several at once (--jobs, one per core by default), each command with its own threads if it takes --threads. Their lines go out in the order the files were given, each as soon as the ones before it are out, so the output is the same at every --jobs.
  • A line per file: nupdf.batch.item/v1 with the file, its size and modification time, the exit code, and the command’s report (report; for text output, text and the report from stderr) or its error report (error). The summary, nupdf.batch/v1, counts the files, those that succeeded, failed, were skipped and were not run. A file that fails does not fail the batch: its line says why, and batch exits 0.
  • Runs can be taken up again. With -o FILE, the lines go to the file, each written as soon as it is in order, and the summary to stdout. --resume reads the lines already there and skips every file they report with the same size and modification time; a file changed since runs again, and its new line is added after the old one, so the last line for a file is the one that counts. A line cut off by a killed run is ended before adding.
  • Arguments can name each file’s output: {stem}, {name} and {index} (the file’s number, zero-padded) in the command’s arguments, as in -- clean -g -o out/{stem}.pdf.
  • --item-timeout bounds each file as a call’s timeout does; the global --timeout bounds the batch, which then starts no more files.
  • batch is not an MCP tool: an agent calls tools at once itself, and a batch’s lines are a stream, not one report.

D44. diff lines pages up by their text, then lines within the pages that changed

2026-11-12

nupdf diff <a> <b> compares two documents by --by text,structure (the default), and pixels on request; the report has a part for each, null when not asked for, and identical when every part asked for is the same.

  • Text: pages first, then lines. Each page’s lines are its stext lines with text, with the content ids extract gives them (p3.b2.l1). The pages of the two documents are lined up by the shortest edit script over their whole text (Myers), so a page taken out or put in is one page deleted or inserted, not every page after it changed. A run of pages gone next to a run of pages new is taken as pages changed, paired in order, and what is left over is pages deleted or inserted. Each pair of changed pages gets the edit script of its lines: delete with the id in a, insert with the id in b, and the line’s text. Past 2,000 edits between two pages, the page is replaced instead; past 2,000 between the documents’ pages, pages are paired by position.
  • Structure: named parts, each compared whole. The page count; each page’s size (its bounds after rotation, to 0.01 pt), rotation, annotation count and image count; each Info entry (info.Title); the outline, as titles indented by level; the form’s field count; and the fonts the text is drawn in (from the text, not from fonts, which follows mutool info and misses fonts it does not look for). A difference names the part and gives a and b, or for the fonts and the outline what is only in each.
  • Pixels: each page drawn at --dpi (36 by default) on both sides, in the same pixels as render, compared exactly: the pixels that differ, their share of the page, the largest difference in a channel, and their bounding box in points. Pages are compared by position, as far as the shorter document goes.
  • --max-items (500) bounds each kind’s list, with truncated on the text.
  • Diagnostics from both documents are reported, each with document: a or b. The report’s doc_id is a’s.

D45. The guide and the skill are made from the CLI’s definition; the schemas are read off its reports

2026-11-12

  • nupdf guide is plain text a model reads at the start of a task, so it is short (1.6 KB, a few hundred tokens): the report envelope and exit codes, the commands for each step of a task with the options that matter (--options reading-order for text that comes out of order, budgets and cursors for long files), and every command by name, listed from the CLI’s own definition so the list cannot fall behind. It was 8 KB at first; the agent eval’s pilot showed agents reading it on every task, at 2,000 tokens each time, and turning to rendered pages where the guide did not say how to get text in reading order. nupdf guide <command> is that command’s full help. nupdf guide --skill is the same text as a Claude Code skill, with the name and the description Claude Code reads to decide when to use it; skills/nupdf/SKILL.md is that output, and a test fails when the two differ.
  • nupdf schema <name> prints the JSON Schema (draft 2020-12) of a report: a command’s (extract), or one of its parts (extract.page, batch.item, error); the report’s own id (nupdf.extract/v1) works too, and nupdf schema lists them.
  • The schemas are read off what the commands print, not written by hand: cargo xtask schemas runs each command, in the variants that change its report’s shape, on every smoke fixture and 400 files of the standard tier, groups the reports by schema, and writes each group’s shape: the types each field has had, the fields every report had as required, arrays by what their items held. Keys that nest under themselves (stext’s blocks) share one definition in $defs, so a block deep in a structure has what blocks have anywhere; an object with more than 24 keys is a map; an array only ever seen empty takes anything. What a shape cannot tell is written in the generator: a PDF object as objects and show encode it (a recursive definition), the Info dictionary as a map of strings, batch.item’s report and error as any report, diff’s values as anything, and page_count as possibly null wherever it appears. A schema allows fields it does not list, since adding a field does not break a reader.
  • They are checked against more than they were read from. cargo xtask schemas --validate standard --sample N runs the same commands on other files of a tier and lists every place a report breaks its schema; at introduction, 150 other standard-tier files broke none. --check, which needs only the smoke tier, checks that every smoke report has a schema and fits it, and the CLI’s tests check a set of reports the same way.

2026-11-12

outline, links, labels and attachments report what MuPDF’s functions return, through the calls mutool run scripts make, and each is checked against oracle/js/navigation.js on every file of a tier.

  • The outline is fz_load_outline: the outline iterator, its repair of /Parent, /Prev and /Last included, read item by item as load_outline_sub reads it, each entry’s page from fz_resolve_link. A tree the iterator refuses (a cycle, or more than 256 levels) fails with input.outline.
  • Links are pdf_load_link_annots on the page’s /Annots, boxes through the page’s transform, as Page.getLinks gives them; the page an internal link leads to is fz_resolve_link’s.
  • Page labels are pdf_page_label: the last /Nums entry at or before the page in the /PageLabels tree, its style (D, R, r, A, a, or none), prefix and start, cut as MuPDF’s 100-byte buffer cuts it; a page outside every range gets its number. MuPDF follows /Kids without a bound; nupdf stops at 64 levels and at a node it has seen.
  • Embedded files are the /EmbeddedFiles name tree walked as the script API’s getEmbeddedFiles walks it, names in the order a script’s record of them has (names that are array indices first, by number), and each file specification as pdf_get_filespec_params, pdf_is_embedded_file (which wants /Type /Filespec) and pdf_verify_embedded_file_checksum read it. Dates are pdf_parse_date’s, its warnings included (date.invalid).
  • Pages load as pdf_load_page would before their links or label are read: the page object must be a dictionary; a page with /Annots must have a transform (its inherited boxes), as MuPDF computes it only then, for the links; and its inherited /Resources must be readable. A page MuPDF cannot load is an error in place, with MuPDF’s message, and one it can is not.
  • The script API’s own failures are not taken as MuPDF’s. getEmbeddedFiles throws on a document with no /EmbeddedFiles tree; the oracle reads that as no files, which is what MuPDF’s functions see.

D47. Signatures are checked as MuPDF’s OpenSSL backend checks them, RSA signers in nupdf’s own code

2026-11-12

nupdf signatures reports each signature field as PDFWidget gives it in a MuPDF built with OpenSSL, and oracle/js/signatures.js holds it to that on every file of a tier.

  • What a field says: signed or not (pdf_signature_is_signed), its name, the signature dictionary’s /Filter, /SubFilter, /Reason, /Location, /Name and /M, the /ByteRange as given, the length of /Contents (read from the file without decrypting it, as pdf_load_unencrypted_object reads it), and whether the file goes on past the last signed byte (pdf_signature_incremental_change_since_signing). A range outside the file, and a gap between ranges that holds anything but the hex signature (validate_certificate_data), are errors in MuPDF’s words.
  • The digest check is PKCS7_signatureVerify’s: the bytes the ranges cover hashed with the signer’s digest; with signed attributes, their messageDigest compared with that hash (“Signature invalidated by change to document.” when they differ), then the signer’s signature over the attributes; without, the signature over the hash itself. The result is in MuPDF’s words (pdf_signature_error_description): “OK”, “No signatures.”, “Unknown error.”. A subfilter of adbe.pkcs7.sha1, whose signed content is a hash of the bytes, fails, as it does in MuPDF.
  • nupdf verifies RSA signers itself (PKCS #1 v1.5, rsa.rs: the public-key operation, on public data, so not in constant time) instead of depending on the rsa crate, whose advisory (RUSTSEC-2023-0071, about private keys) would fail cargo deny. A signer with another kind of key (ECDSA, RSA-PSS) is “Unknown error.” here, where OpenSSL would check it. Signatures are read with a small DER reader that also takes BER’s indefinite lengths, as OpenSSL does (der.rs, fuzzed with the forms target).
  • The signatory is the subject of the certificate the first signer names by issuer and serial number (names compared as X509_NAME_cmp compares them: entries in order, values trimmed, white space collapsed, ASCII lowercased), as cn=..., o=..., ou=..., email=..., c=..., else “Signature information missing.”.
  • Not checked: the certificate’s trust (MuPDF’s answer depends on the machine’s trust store), and pdf_validate_signature’s comparison of the fields a signature locks across the file’s versions.
  • Saving a signed file: every command that writes reports, for a signed input, each signature’s check before and after (output.signatures), and warns (signature.invalidated) when a signature that held no longer does. Only --incremental keeps the signed bytes, so that is what the warning says.

D48. sanitize removes what a document can do besides show itself, and proves it by scanning the output

2026-11-12

MuPDF has no command for this; sanitize is nupdf’s own.

  • Every object is walked, so an action is found wherever it hangs: /A, /OpenAction, /PA, /Next chains (one action or an array of them), each trigger of /AA (page, annotation, field, document), outline items, and the /Names /JavaScript tree. An action that is an object of its own is emptied and the keys pointing to it dropped; a direct one goes with its key. /AA dictionaries left empty go.
  • Removed by default: JavaScript (JavaScript actions, Rendition actions with /JS, the document’s scripts, the form’s calculation order /CO), Launch, SubmitForm and ImportData (data), GoToR and GoToE (remote), and XFA (the AcroForm’s /XFA, the catalog’s /NeedsRendering), since an XFA form carries scripts of its own. --keep javascript,launch,data,remote,xfa keeps any of them.
  • By choice: web links (--uri keep, the default; remove; or allow:HOST,..., which keeps links to those hosts and their subdomains), and embedded files (--attachments: the /EmbeddedFiles tree and FileAttachment annotations’ files).
  • Each removal is reported with the object, the key path inside it (/AA/K, /Names/JavaScript), its kind and what it said (the script’s first characters, the URI, the program), with counts by kind.
  • The output is checked before it is written: saved, read back and scanned with the same policy; anything left is exit 4 (verify.failed), as for redact.

D49. Annotations, forms and flattening follow MuPDF to the byte

2026-11-12

  • annots lists each page’s annotations as pdf_sync_annots does (dictionaries in /Annots, links, popups and widgets aside), with what the getters read: type, flags, contents, colours, opacity, dates, and, for the types that have them, rect, author, subject, interior colour, icon, open, border, line, vertices, quad points and ink list, in page space. An annotation without an appearance is read as loading the page leaves it, with the appearance MuPDF makes for it (its /Rect and /RD can change). oracle/js/annots.js checks it on every file.

  • Appearance synthesis (appearance/) is pdf-appearance.c, writer by writer: the shapes, text markup, icons, stamps, free text, line captions and widgets (text, choice, push buttons, check boxes, radio buttons). The annot-synthesis and widget-synthesis checks make every appearance again on both sides (requestResynthesis, then update) and compare the streams byte for byte, with their /Rect, /RD, BBox, Matrix and Resources. Arithmetic is C’s, on f32, in C’s order, with a * b + c as a fused multiply-add where clang makes one (the left product of a*b + c*d). Not covered: text MuPDF lays out with its HTML engine (free text and fields with /RC, /RV or /DS, or characters no base-14 or CJK font has), which is an error here (annot.appearance), and unsigned signature fields, which MuPDF draws through a display list.

  • forms lists each page’s widgets with what the field getters read: full name (the /Parent chain mended as pdf_repair_page_tree_parents mends it, which also makes each widget’s parent its page), label, type, flags, value, options, maximum length, read-only and signed; with need_appearances and xfa for the document. sanitize strips XFA.

  • flatten is pdf_bake_document: on each page, what has no appearance gets one, written into the document; then each annotation (--annots-only) or field (--widgets-only), or both, is drawn into the page as a form XObject (AnnotN) with the transform that maps its box onto its /Rect, after enough q and Q to start from the page’s state, and removed from /Annots; links stay; with fields, /AcroForm goes. MuPDF’s quirks are kept: a direct /Resources inherited by a page is shared with the node it comes from and every page that took it, as one object is in MuPDF; an annotation stored inside /Annots gets its form made but the page never shows it; a Stamp with a name MuPDF does not draw keeps the appearance made on loading the page. The flatten and flatten-annots checks compare the written files’ BLAKE3 with MuPDF’s.

  • Editing annotations: annots --add JSON creates one as pdf_create_annot does, with each type’s defaults (flags, rect, colour, a Text note’s popup, FreeText’s DA, a Stamp’s “Draft”), then applies the fields given through MuPDF’s setters, in page space, in a fixed order the oracle uses too; the fields are the listing’s, so a listed annotation can be passed back. --delete ID is pdf_delete_annot (its popup goes with it), ids from the listing before any change; --highlight TEXT adds a Highlight on each search hit. The changed annotations get appearances on the page update, in the order MuPDF updates its open pages (newest first). MuPDF’s quirks are kept: a direct page is shared through /P; a direct annotation without an appearance cannot be deleted (MuPDF deletes the copy it made on loading), which is reported as not_deleted.

  • Filling fields: forms --set NAME=VALUE and --toggle NAME are setTextValue, setChoiceValue and toggle with JavaScript off (scripts_run: false): /V on the field, /AS on its widgets, radio groups turned together, every widget of the field made again, on every page. MuPDF sets read-only fields and choice values outside the options; so does nupdf, and the report notes it.

  • Writing appearances: the fonts the writers need are added as objects of their own, before the form that uses them, one per font and encoding for the whole document, as MuPDF’s font cache keeps them (appearance::commit).

D50. OCR runs Tesseract and lays its words over the page as invisible text

2026-11-12

  • ocr draws each page at --dpi (300), runs tesseract <image> stdout -l <lang> --dpi <dpi> tsv as a subprocess (with a time limit a page), and puts the words it reads on the page in render mode 3, each scaled to the box Tesseract gave it, after the page’s contents and outside their graphics state. The page looks the same; its text can be searched, selected and extracted.
  • The font has no glyphs, as Tesseract’s own PDFs have it: a Type 0 font (Identity-H) whose codes are the characters’ UTF-16 units, every glyph half an em wide, with a ToUnicode map from each code to itself. Characters outside the Basic Multilingual Plane become U+FFFD.
  • Only scanned pages, unless asked: a page with no text and images over at least half of it. --pages and --all choose otherwise; --min-confidence leaves out words Tesseract is unsure of.
  • info says which pages look scanned (text.scanned_pages, and text.next names the command), from a copy of the document of its own so that drawing the pages adds nothing to what info reports. Documents of more than 2,000 pages are not looked at page by page.
  • Tesseract is not bundled. Without it, ocr fails with ocr.unavailable and how to install it; --tesseract PATH names another program, which is how the tests run it.

D51. extract --format layout puts each row of a page on one line

2026-11-13

  • Why: in MuPDF’s text order a label and its value are often far apart. Generated forms and invoices draw values in their own blocks, sometimes before the labels, so Total and 393.25 come out lines apart or reversed. In the agent eval, agents that met that text rendered the pages and read the images, which cost several times the tokens of the text (eval/REPORT.md, token follow-up).
  • What it does: horizontal lines whose baselines are within half a line of each other share a row; rows go down the page, lines across it, each at the column its left edge gives (one column per the page’s median character width, counted from the page’s leftmost text), at least a space apart. A gap between rows of more than two lines’ height becomes one empty line. Lines that are not horizontal follow, a line each. Rows are cut off at column 400, so text placed far off the page cannot make a row of millions of spaces. A form feed ends each page, as in text.
  • Its own format, not MuPDF’s: MuPDF has no such output, so text stays fz_print_stext_page_as_text (D5) and layout is opt-in. --options apply as for the other formats.
  • Budgets count whole pages: rows cross blocks, so --max-chars and --max-items stop before a page, and next_cursor names a page.

D52. Pages are drawn whole, annotations and widgets included, as mutool draw draws them

2026-11-13

  • Why: render drew the page contents alone (page.runPageContents), but mutool draw runs fz_run_page: the contents, then the annotations, then the widgets. A filled form, a stamp or a comment was missing from nupdf’s pixels, and extract --format text, which says it is what mutool draw -F txt writes, was missing their text. D41 left this for M9, which made the appearances.

  • What is drawn (page_run, from pdf_run_page_with_usage, pdf_run_annot_with_usage and pdf_process_annot): the page’s annotations as pdf_sync_annots lists them (never links or popups), then its widgets, each in a run processor of its own with the page’s resources and default color spaces. Not drawn: a widget without an inheritable /FT and /T; Invisible or Hidden ones; NoView ones for “View” (only Print ones for “Print”); those whose /OC is off; those without an appearance. The appearance is pdf_annot_ap’s (/AP /N when it is a stream object, else the state /AS names), drawn as q, the cm of pdf_annot_transform (its /BBox through its /Matrix onto the display rectangle: /Rect, resized to the box as stored for NoZoom, turned with the page for NoRotate), Do, Q. Annotations are not clipped to the CropBox and not inside the page’s transparency group, as in MuPDF. An error ends the page’s run where it happens, as it leaves fz_run_page: after an error in the contents no annotation is drawn.

  • Appearances made on loading stay out of the document: what pdf_update_appearance makes when the page loads (for an annotation without an appearance, for every widget when the form has /NeedAppearances, and for an unsigned signature, whatever it has) MuPDF keeps in a local xref, with the annotation’s new /Rect. nupdf makes the same with appearance::synthesize and keeps the form as a local stream of the document (Document::add_local_stream: a negative number no object has, its data dropped with it), with the changed annotation as a copy. Rendering leaves the document unedited: annots, flatten and a write after it see what they would have seen. An unsigned signature shows MuPDF’s sign-here arrow (pdf_signature_appearance_unsigned, “SIGN” fitted by pdf_layout_fit_text); MuPDF makes that form through its PDF-writing device, nupdf writes the same drawing directly, so only the pixels are MuPDF’s, and only render and text use it.

  • Each command follows the MuPDF function it names: render, redact --preview and diff --by pixels draw the whole page (render --no-annots for the contents alone). extract --format text is mutool draw -F txt: fz_run_page into a display list, played into the stext device as fz_run_display_list plays it (list_run: each call’s rectangle as the list device computes it, a clip’s narrowed at its pop to the union of what was drawn inside, calls with nothing to show left out). A text device reads its clips, so the list matters: text whose quad lies mostly outside the box of what its clip holds is not gathered, as mutool draw -F txt leaves it out and mutool draw -D keeps it. --no-annots gives the contents alone, run directly as before. stext, search, tables, trace and extract‘s JSON, Markdown and layout stay with the contents, as Page.toStructuredText, fz_search_page and fz_new_stext_page_from_page run them, so content ids mean the same block everywhere and redact --id finds what extract named. ocr draws the contents alone: its text layer is for the scan, and an annotation’s text is its own. Redaction is unchanged: its targets come from the contents’ text, every annotation and widget over an area goes whatever it shows, and verify reads annotation contents, field values and the strings appearance streams show from the objects themselves.

  • Checks: render-annots-72 and render-annots-72-icc compare whole pages with MuPDF’s page.run into a draw device; text-annots compares extract --format text with each page’s toDisplayList(true).toStructuredText().asText() and a form feed, which is what mutool draw -F txt writes; the contents-only render-* checks run render --no-annots. Measured on 2026-11-13, before (nupdf’s contents against MuPDF’s whole page) and after:

    CheckStandard tier (5,142 files)Wild sample (1,000 files)
    render-annots-7294.1% → 98.9% of files; 98.35% → 99.74% of 19,790 pages94.7% → 95.6% of files; 98.43% → 98.68% of 13,041 pages
    render-annots-72-icc97.8% of files; 99.36% of pages89.4% of files; 95.52% of pages
    text-annots95.3% → 99.4%70.8% → 99.3%

    Whole pages match exactly as often as the contents alone do (render-72: 5,070 standard files, 954 wild ones), but for the two rich-text annotations below; the colour-managed check’s remaining differences are those of render-72-icc, CMYK under an output intent’s profile, which the annotations of two Isartor files draw in. Of text-annots’ 29 standard mismatches, 28 are rich text and 1 is contents’ text that comes out of the list differently; the wild sample’s 7 are in the contents’ text.

  • Known differences: a FreeText or text field whose text needs rich layout (an /RC or /DS, or characters neither the base 14 fonts nor MuPDF’s CJK fonts have) gets its appearance from MuPDF’s HTML story engine, which nupdf does not have: nupdf warns annot.appearance and draws nothing for it.