--format jsonsilently returned the whole document as a single page. The output advertisedpage_count: 88butpageswas a list of length 1 holding the entire document as one blob — so the per-pagetext+ocrstructure, the whole reason the JSON format exists, was lost for every multi-page document. Root cause:formatters/json_fmt.pyrebuilt the per-page array by splitting the already-joinedcontentstring on a"\n\n---\n\n"separator, but the pipeline joins pages with"\n\n"(pipeline.py:326) and drops empty pages while doing so — the separator is never present, so the split always fell through topages = [text]. Silent, plausible-looking, and wrong: exactly the failure mode this package exists to surface.- Fix: the pipeline now threads the real
PageResultlist to the formatter (format_json(..., page_entries=...)) instead of asking it to reverse-engineer page boundaries from a lossy joined string.pagesnow has one entry per source page —{"page": <1-indexed>, "text": ..., "ocr": <bool>}— including empty pages (a consumer can now see that pages 6/14/28/54 were blank), withlen(pages) == page_count. - The separator-split path is kept as a fallback for standalone callers that only have a joined string, so importing
format_jsondirectly still works. - No schema change (
schema_versionstays1.1.0) — this restores the per-page contract the schema already documented. Confidence scoring is untouched:eval/run_eval.pyscores are byte-identical andeval/calibrate.pystill reports precision 1.00 / recall 0.82.
- Fix: the pipeline now threads the real
tests/test_json_formatter.py(5 cases) — pins that suppliedpage_entriesare authoritative, that a"\n\n"-joinedcontentis never re-split into one page, that the separator fallback and single-blob fallback still work, and that control characters are stripped from per-page text.test_process_json_per_page_not_collapsedintests/test_pipeline.py— end-to-end regression assertinglen(pages) == page_counton the 5-page fixture.
- Font-size heading detection turned magazine/report layouts into a wall of H1s — the exact silent-garbage failure pdfmux exists to catch, pointed at itself. On a real 88-page document (Knight Frank Wealth Report 2025)
pdfmux convertpromoted 655 lines to#headings and still reported 97% confidence. The masthead rendered as ~16 stacked H1s (a single email became three:# CONTACTS firstname./# familyname@/# knightfrank.com), and display-type standfirsts were split one-heading-per-wrapped-line (# affecting how you live, work,/# invest and give back). Root cause:_assign_levelspromotes any line>= body_size * 1.2, but on a sparse cover/masthead the most-common font by character count is a small caption/legal font, sobody_sizeis under-estimated and nearly every display line clears the bar — with no page-level sanity check that the heuristic had failed.- Three high-precision, generalizable guards, applied in
_clean_false_headingsso both the injection path and the extractor-provided (early-exit) path are covered — none tuned to specific strings:- Lowercase-start / mid-phrase demotion (
_looks_like_heading): a real title never begins with a lowercase letter, nor ends on a dangling,;:–&or function word (and,to,of,the, …). Kills the wrapped-prose lines (their exposure to real estate, a sector they,Retrofitting and). - Page-saturation guard (
_desaturate_headings): when ≥6 lines and ≥50% of a page's content lines are headings, the census is unreliable for that page — emit no headings rather than confident garbage. Clears the masthead entirely. - Heading-run collapse (
_collapse_heading_runs): a run of ≥5 back-to-back headings with no intervening body prose is a list/table/menu, not an outline — demoted to text. Country/asset tables stop rendering one-H1-per-row.
- Lowercase-start / mid-phrase demotion (
- Net effect on the report: 655 → 379 headings, with the masthead, split email, and shredded standfirsts gone. Legitimate sub-headings that head real paragraphs (country names over their visa write-ups) are kept — the run-collapse only fires on prose-free runs.
- Zero eval regression:
eval/run_eval.pyscores are byte-identical to the committed baseline;eval/calibrate.pystill reports precision 1.00 / recall 0.82. Full suite green (759 passed).
- Three high-precision, generalizable guards, applied in
TestOverInjectionGuardsintests/test_headings.py— 8 cases pinning the three guards, with real-heading survivors (Introduction,Our contributors,ESG top picks) asserted alongside the demoted false positives, drawn from the Wealth Report fixtures.
Unreleased — port the MCP server to mcp 2.x; retract "Gemma 4"; drop the phantom pdfmux[arabic] extra; de-flake the CLI tests
pdfmux[serve]now requiresmcp>=2.0.0, and drops support for mcp 1.x. mcp 2.0.0 deleted themcp.server.fastmcpsubmodule thatmcp_server.pyandmcp_extract.pyimported, so every fresh install of the extra broke withModuleNotFoundError: No module named 'mcp.server.fastmcp'. The previous "fix" (commit6a4f78a) was an upper-bound pin —mcp>=1.0.0,<2.0.0— which does not fix anything, it defers: the port still has to happen, and until it does, every dependabot bump re-reds CI and gets closed again. This ports the code instead.FastMCP→mcp.server.mcpserver.MCPServer, which is a near drop-in: same.tool()decorator, same.run(transport=…), samename/instructionsconstructor kwargs, same_tool_managerregistry. Nothing in pdfmux's own API changed —run_server(),run_http_server()and the module-levelmcpobject all keep their names and signatures. Users pinned to mcp 1.x must upgrade; that is the deliberate trade for ending the loop.- HTTP bind address now travels as a
run()kwarg, not throughmcp.settings. This is the part the pin hid, and it is a real runtime break rather than an import error: mcp 2.x'sSettingsmodel no longer carrieshost/port, somcp.settings.host = hostraisesValueError: "Settings" object has no field "host". A pure import-path swap would have imported cleanly, passed the existing tests (which only assertrun_http_serveris callable) and then crashed on the firstpdfmux serve --http. Both servers now callmcp.run(transport="streamable-http", host=host, port=port). Verified end-to-end, not just by assertion: the HTTP server binds and answers a realinitializehandshake on/mcp, and both stdio entry points (pdfmux serve,pdfmux-extract) complete one over stdin.
test_server_uses_the_mcp_2x_import_surface— asserts both server instances aremcp.server.mcpserver.MCPServer. Fails with a message naming the dependency when the installedmcpis <2.0.0, so a re-pin surfaces as a test failure that says why, rather than as a collection-timeModuleNotFoundErrorthat reads like an environment problem. A comment on a pin is not a guard.test_run_http_server_passes_host_and_port_to_run/test_run_http_server_defaults_to_loopback— assert the bind address reachesrun(), deterministically, without binding a socket. Both were confirmed to fail against the oldmcp.settings.hostform before being kept — a guard verified only in the passing direction is not a guard. The loopback default (127.0.0.1, overridable viaPDFMUX_HTTP_HOST) is now covered too, so the safer-bind behaviour cannot silently regress to0.0.0.0.
-
pdfmux/arabic.pytold users to runpip install pdfmux[arabic]. That extra has never existed. Both the module docstring andfix_bidi_order's docstring carried the hint, and it is worse than a typo in two compounding ways: pip accepts unknown extras silently, so the command succeeds and installs nothing; andpython-bidiis already a core dependency, so RTL reordering was working the whole time. A reader hunting a BiDi problem would run the command, see success, see no change, and conclude BiDi was broken. -
A missing
python-bidino longer fails silently.fix_bidi_ordercaughtImportErrorand returned the text unchanged with no signal. Becausepython-bidiis core, that branch means the install is broken — and its output is Arabic in storage order: reversed, but entirely plausible-looking to anyone who does not read Arabic. Silent, plausible, wrong output is the failure mode this package exists to surface, so it should not be the one path that ships it. It now logs a warning naming the real cause (broken install, not a missing extra) and still returns the text rather than raising, so a degraded environment does not become a hard failure on documents that may contain no Arabic at all. Cached so it warns once per process, not once per page. -
The test suite was implicitly a performance test, and flaked because of it.
pipeline.EXTRACTION_TIMEOUT_Sis read fromPDFMUX_TIMEOUTat import time (default 300s), so every test driving the CLI inherited a wall-clock dependency on whatever machine ran it.pdfmux analyzecallsprocess(quality="standard")— the full BALANCED chain, loading Docling / Marker / opendataloader — so a one-page synthetic fixture takes 30-40s idle, and on a contended machine it crept past the deadline. Two full-suite runs of byte-identical code disagreed: 762 passed vs 2 failed.- Measured blast radius, not just the file that flaked: running each CLI test file under
PDFMUX_TIMEOUT=1shows the latent dependency in four files —test_analyze(3),test_cli(4 of 9),test_diff(5 of 6),test_watch(2 of 3).test_audit_cli,test_estimate,test_manifest_verify,test_profiles,test_streamingandtest_verifierare clean. The fix therefore lives inconftest.py, not in the one file that happened to surface it. - An autouse fixture now pins
EXTRACTION_TIMEOUT_Sto 1800s. It patches the module attribute, not the environment variable — settingPDFMUX_TIMEOUTfrom inside a test does nothing, because the constant is already computed by then.test_timeout_isolation.pystill overrides it locally with a sub-second value, so the wedged-extractor path stays covered.
- Measured blast radius, not just the file that flaked: running each CLI test file under
-
A timed-out CLI test now says so. These tests asserted
result.exit_code == 0, which reportsassert 1 == 0and cannot distinguish "the command is broken" from "extraction timed out on a busy machine" — that ambiguity is what made the flake expensive to diagnose. The newassert_cli_okhelper surfaces the exit code, the underlying exception, and the captured CLI output, and names the timeout explicitly (reporting the value in force, not the pinned constant, since a test may have overridden it). -
Every user-facing surface called the Gemma backend "Gemma 4". It serves Gemma 3.
providers/gemma.pyhas always setdefault_model = "gemma-3-27b-it"and advertised exactly two models,gemma-3-27b-itandgemma-3-12b-it— but the README,docs/ARCHITECTURE.md, thepdfmux doctorrecommendation incli.py, theROUTING_MATRIXcomments, two test docstrings, and the 1.8.7 changelog entry all said Gemma 4. The docs were renamed ahead of the code and nothing caught it.- The tell was internal, not external: the README's provider table read
| Gemma 4 | 27B IT, 12B IT |. Gemma 4 has no 27B size (its sizes are E2B, E4B, 12B, 26B A4B, 31B) — 27B is a Gemma 3 size. The row named one generation and listed the other's sizes. - Not cosmetic. The README told Arabic users their pages route through a model pdfmux never requests, and the claim had propagated to the public blog, which repeated "Gemma 4 27B" — a model that exists in neither generation's lineup.
- The tell was internal, not external: the README's provider table read
-
Corrected the Gemma per-page cost in the README:
~$0.005/page→~$0.0002/page. The figure was ~27x the provider's own estimator.GemmaProvider.estimate_cost()computes $0.000185/page from its declared rates (460 input + 500 output tokens at $0.075/$0.30 per Mtok); the README asserted a number nothing in the code produces. -
Fixed a broken install command in the README's Arabic section. It read
pip install "pdfmux[arabic,llm-gemma]". Neither extra exists —pip installaccepts unknown extras silently, so a reader following the Arabic quickstart installed nothing and believed they had enabled Gemma vision OCR. The Gemma provider imports theopenaiSDK, so the correct extra isllm-openai(also included inllm-all).
test_no_install_instruction_names_a_nonexistent_extra— everypip install pdfmux[...]acrosssrc/, the README, anddocs/must resolve to a real extra inpyproject.toml. This defect class has now shipped three times (pdfmux[arabic],pdfmux[arabic,llm-gemma], andpdfmux[local]on the blog) precisely because it fails silently at every layer. The check matches install instructions only, so prose that names a bad extra in order to rule it out still passes — a blunt scan would have been switched off, which is how the original hint survived.assert_cli_okintests/conftest.py— shared helper for CLI-driving tests, plus an autouse fixture pinning the extraction timeout (both described above).
- Gemma 4 is real and is on the Gemini API —
gemma-4-31b-itandgemma-4-26b-a4b-it(Google's docs). Adopting it here was deliberately not bundled into this fix: the OpenAI-compat path this provider uses is undocumented for those IDs, and the pricing constants andmax_input_tokenswould both need re-verification against a live key. Doing the rename without that verification is the same defect in the other direction. The requirements are recorded in theproviders/gemma.pymodule docstring. scripts/release-gate.shnow fails when a shipped artifact claims "Gemma 4" while the provider's model IDs saygemma-3-, so this specific drift cannot ship again.
- Arabic documents were never routed to an Arabic-capable backend.
_classify_to_page_typehas always returned"arabic"for them, butROUTING_MATRIXhad no"arabic"rows, so every Arabic document fell through toDEFAULT_CHAIN— whose BALANCED arm is("opendataloader", "pymupdf")and never reaches an LLM. The route was computed and then thrown away. That contradicted the comment atpipeline.py:527("PyMuPDF/RapidOCR are unsuitable on Arabic-heavy docs") and the README's documented behaviour ("route Arabic pages through Gemma 4 instead of PyMuPDF") — which was therefore untrue. Added("arabic", …)rows: ECONOMY stays free (pymupdf, with BiDi applied post-extraction as before); BALANCED and PREMIUM lead withllm, which resolves to the best available provider — the Gemma provider is the only backend advertising anarabiccapability — and fall through topymupdfwhen none is configured, so nothing breaks without an API key.
table_truncated— a new verifier flag for tables that survive but lose most of their rows. GT-0 measured this blind spot:_has_tabletests presence, so a table cut from 40 rows to 3 still has ≥2 pipe rows and passes. The new check compares cardinality both document-wide and for the largest contiguous table block — the block test is what catches truncation of one table among several, where the document-wide total barely moves.⚠️ RETRACTED 2026-07-27, same day. The "7 of 7" figure first published here was measured the wrong way and is false for the product's actual path. It applied the seeding rule to the hand-written ground-truth Markdown and tested the flag directly. Butverify_extractioncompares an extraction against the source re-derived from the PDF — and that re-derived text usually contains no Markdown pipe-table markup at all (measured: 0 pipe rows for bothbls-empsitandirish-census-1926, versus 11 and 7 in their hand-written GT). With zero source rows the>= 4guard never passes, so the check cannot fire.- The honest number, measured end-to-end with
validate_verifier.pyon pdfmux 1.8.7: of 4 applicable seeded truncations, 1 is detected — a 75% false-negative rate, against 80% before the flag existed. Still missed:arxiv-2203.02155-instructgpt,bls-empsit,irish-census-1926. Table completeness should be treated as unsolved. The flag only helps when the source itself is Markdown (e.g.--extractedMarkdown-vs-Markdown), not when the source is a PDF. - Run over 23 of the 24 corpus documents;
gao-24-106214could not be fetched (gao.gov returns 403 to automated fetch regardless of User-Agent, and it has no Wayback capture).bls-empsitwas recovered from a Wayback snapshot whose sha256 matches the pin exactly. - Purely additive.
_has_table,table_integrity, and all five pre-registered GT-0 thresholds are byte-identical, so the published FP/FN figures remain valid for every signal they measured. A page that would have passed now surfaces asreview, never as a new hard failure.
- Retracted a false benchmark claim. Every pdfmux surface described
opendataloader-hybrid(0.909, the one engine scoring above pdfmux) as "the paid hybrid engine" and claimed pdfmux was "#1 free". Both are wrong.opendataloader-pdfis Apache-2.0 (~28k stars) and the benchmark's hybrid mode ishybrid="docling-fast"— no API key, no token, no network call in the adapter. The top-scoring engine is free and open source, so "#1 free" was not imprecise, it was false. The README table also labelled itCommercial | API; corrected toApache-2.0 | No. - The defensible claim, used everywhere now: 0.903 overall — #2 of the 8 engines measured. Docs-only; no code changes.
scripts/release-gate.shnow fails on#1 free,#1 among freeandpaid hybrid engine, so this specific claim cannot ship again.
- The PyPI Summary cited a superseded benchmark score (0.905). The reproduced result for the current engine is 0.903 (#2 of all tools, #1 free on opendataloader-bench). The long description already said 0.903, so the package page contradicted itself on the line most developers read first. Docs-only — no code changes.
- Added
scripts/release-gate.sh, which runs against the built wheel before upload and fails on: banned claims and dead links in the metadata or the shipped code, a superseded benchmark score presented as pdfmux's, a Summary whose score disagrees with the canonical one, and a hardcoded__version__. Each of those checks exists because that exact defect shipped: 1.8.1 (unverified benchmark claim), 1.8.3 (version drift into signed manifests), 1.8.4 (this).
pdfmux.__version__was hardcoded and had drifted. 1.8.3 shipped to PyPI reporting itself as1.8.2:pyproject.tomlwas bumped, the literal in__init__.pywas not. It is now derived from the installed package metadata (importlib.metadata.version), so the two can never disagree again.- Not cosmetic:
verifier.pystamps this string into every certification manifest as thetoolfield, so a stale literal made a signed artifact misstate which engine produced it.pdfmux --versionandpdfmux doctorwere wrong for the same reason.
pdfmux verify-manifest <file>— verify a pdfmux Cloud Ed25519-signed manifest offline, with no pdfmux account and no network call. Verification is free and open (MIT) forever; only generation is paid. You should never need our permission to check our work.
- Removed a dead link shipped in the CLI.
pdfmux convertandpdfmux auditpointed atverifiedextraction.org, which does not resolve (NXDOMAIN) — every interactive user was handed a broken URL. Both lines now point at real, live surfaces. - The cloud pointer sold "a free dashboard", which is not a reason to leave your terminal. It now names the actual difference: a third-party-verifiable signed attestation a local install definitionally cannot produce.
- Extraction timeout now hard-terminates a wedged extractor.
PDFMUX_TIMEOUT(default 300s) previously raised on schedule but could still hang the caller: aThreadPoolExecutorcannot cancel a thread that is already running, and its context-exit blocks onshutdown(wait=True)until the native call returns — which, for a wedged PyMuPDF / OCR / Docling page, may be never. Extraction now runs underpdfmux._timeout.run_with_timeout: on Linux it forks a child and escalatesSIGTERM→SIGKILLat the deadline (the wedged extractor is actually killed and its memory reclaimed — the path the cloud worker runs); on macOS / Windows, where forking after native libraries load is unsafe or unavailable, it falls back to a daemon thread so the caller is freed immediately and process exit is never blocked. Override withPDFMUX_TIMEOUT_ISOLATION(auto|process|thread|off). Still surfaces as the existingOCRTimeoutError. Resolves the portfolio-audit P1 finding.
Docs-only release (no code changes). Corrects the README / PyPI description: removes an unverified "#2 on opendataloader-bench" benchmark claim; replaces it with the real, git-dated 433-document customer batch (naive v1: 16 silently dropped, 11 with no log line → rebuilt: 433/433, 0 silent); aligns the patent-pending method description with LICENSING.md (drops the not-built "runtime calibration"; "ships in" → "reserved for"); and seeds the expanded proposition — Certify Anything: audit any extractor's output for silently-dropped pages.
Additive release. No breaking changes, no defaults change. Builds on 1.8.0.
pdfmux verify— Certify Anything. Audit ANY extraction engine's output against the source PDF, not just pdfmux's own. Point it at a source PDF plus an external extraction (Reducto, Mistral OCR, LlamaParse, Docling, an in-house parser — anything) and it re-derives the source text with pdfmux's own audit pass, aligns the extraction to it, and scores every page for coverage, confidence, alignment, hallucination risk, table/heading integrity, and silent drops — the failure where the source page has real text but the engine returned nothing while reporting success.- Public API (
src/pdfmux/verifier.py):verify_extraction()returns a single-documentCertificationManifest;verify_batch()returns aBatchCertification— the "M pages silently dropped across N docs" report. Both re-exported from the top-level package. - CLI (
pdfmux verify): single-doc and batch, table/json/markdown output,--strictCI gate (exits non-zero unless PASS), manifest + report file output. - MCP tool (
verify_extraction, 7th tool inpdfmux serve): given a document path and an engine's extracted text, returns the per-page usable / silently-empty / recovered audit plus the "N of M pages silently dropped" summary. - Each manifest carries a tamper-evident SHA-256 content signature over its canonical body and an embedded, honest LIMITATIONS list (the certifier is lexical, not linguistic — it does not detect faithful paraphrase or translation).
- Public API (
eval/build_fixtures.pysilently shipped a garbage Arabic fixture — PyMuPDF's default font substituted notdef glyphs (middle-dots) instead of raising, so the "Arabic" fixture held zero Arabic codepoints and the eval reported success anyway (the exact failure pdfmux exists to catch). The generator now renders with a real Arabic font and self-verifies the glyphs survive extraction, raising rather than shipping garbage. The 3 regenerated Arabic PDFs are committed so CI needs no Arabic font.eval/README.md+ CHANGELOG published stale eval numbers beside the broken harness. Corrected to the real current table, with an explicit caveat that the internal eval set is a small regression guard, not a benchmark — the sanctioned proof is the ARK 433-document batch.LICENSING.mdoverclaimed. It said the patent-pending decision-trace method "ships in pdfmux Cloud / Pro" (it ships nowhere yet) — corrected to "reserved for" — and listed a "runtime calibration loop" that exists in no product — removed. The MIT / patent boundary is unchanged.
This release remains MIT in full, consistent with LICENSING.md. Certify Anything reuses only pdfmux's shipped MIT audit layer (score_page, audit_document). The patent-pending decision-trace method — the persisted per-page decision trace, the monotonic repair guard, and the runtime calibration loop — is deliberately not included in this repository and is reserved for a separate commercial license.
Additive release. No breaking changes, no defaults change.
extract_pdfMCP server (pdfmux-extract,src/pdfmux/mcp_extract.py) — a single-tool MCP surface that takes a PDF path and returns extracted text, so MCP hosts can call one tool instead of composing several. Documented inintegrations/mcp-extract-pdf/README.md.- Model cost × quality A/B harness (
eval/ab_models.py,eval/build_ab_dataset.py,eval/ab_datasets/) — compares extraction models on a fixed 20-document ground-truth set and reports accuracy against cost per MTok, so model selection is measured rather than assumed. use_cacheparameter onextract_json()— setuse_cache=Falseto force a real re-extraction and bypass the smart result cache.
eval/run_eval.py --no-cachewas a dead flag. It silently returned cached results, so an "uncached" eval run could complete in ~0.1s and report stale numbers.use_cacheis now threaded throughextract_json()intoprocess(), and--no-cacheforces a genuine re-run.- CLI advertised a free tier 10× larger than the real one. The
auditsummary and the post-convert upsell both printed "Free 1,000 pages/mo"; the actual free-tier quota is 100 pages/month (enforced in the Cloud API). Corrected to 100 in both places. - CLI pointed at a dead URL. Both lines linked to
https://pdfmux.com/cloud, which returns 404. Nowhttps://app.pdfmux.com.
This release remains MIT in full, consistent with LICENSING.md. The patent-pending decision-trace method — the persisted per-page decision trace (including retained rejected candidates), the monotonic repair guard, and the runtime calibration loop — is deliberately not included in this repository and ships only in pdfmux Cloud / Pro under a separate commercial license.
This release changes default behaviour on pdfmux convert. Existing pipelines that relied on warn-only confidence handling need one of: (a) pass --no-strict to restore 1.6.x behaviour, (b) raise extraction quality so all documents score ≥ 0.75 confidence, or (c) lower --min-confidence below the documents your real corpus produces.
--strictis now ON by default. Pass--no-strictto opt out.pdfmux convert ./docs/now exits 3 if any document's confidence falls below--min-confidence. Previously this only happened with explicit--strict.--min-confidencedefault is now0.75(was0.0). Combined with default--strict, this means:pdfmux convert ./docs/flips from "warn only, exit 0" to "fail the run if any document is below 0.75 confidence." Calibrated against the 50-fixture eval set at precision 1.00 (see release 1.6.3 notes).
| Your 1.6.x command | 1.7.x equivalent |
|---|---|
pdfmux convert ./docs/ (warn-only) |
pdfmux convert ./docs/ --no-strict --min-confidence 0.0 |
pdfmux convert ./docs/ --strict --min-confidence 0.75 |
pdfmux convert ./docs/ (now the default) |
CI: pdfmux convert ./docs/ ; echo "exit=$?" (always 0) |
Same command now exits 3 on low-quality batches — fix at intake or pass --no-strict |
The 433-PDF customer batch silent-failure incident (April 2026) that drove 1.6.1's --strict flag taught us that opt-in quality gates aren't enough — the failure mode is forgetting to pass the flag. 1.7 makes safe-by-default the actual default. Power users who want to ingest noisy corpora and triage downstream can still do that with one extra flag.
The VEM (Verified Extraction Manifest) spec also assumes strict default behaviour as the reference implementation. Cloud-side enforcement in pdfmux-cloud's worker depends on it.
- All other CLI flags and behaviours.
- Library/programmatic API (
from pdfmux import convert) — defaults match the CLI but can still be overridden per call. pdfmux audit,pdfmux mcp,pdfmux watch,pdfmux diff— no changes.
Additive release. Two new things, no breaking changes, no defaults change. This is the OSS half of the Verified Extraction Manifest (VEM) standard — the audit command produces the comparison artifact that VEM 1.0 standardizes.
-
pdfmux audit --against <other.csv|other.json> --on <pdf-dir>— diff your current extractor's output against pdfmux on the same PDFs.- Reads
--againstas either CSV (columnsfilename,textor aliasesfile,content) or JSON ({filename: text}). - Runs pdfmux on every PDF named in
--againstthat exists in--on. - Computes per-document word-set Jaccard overlap between the two extractors.
- Flags documents with
overlap < --overlap-threshold(default0.70) ORpdfmux_confidence < --confidence-threshold(default0.50). - Writes a 7-column CSV:
filename, my_extractor_chars, pdfmux_chars, jaccard_overlap, pdfmux_confidence, recommendation, error. - Exit codes:
0everything clean,2usage error,3anything flagged (matches--strictconvention).
Use case: "diff our output against your current extractor on 100 of your own PDFs — if we agree on every document, you don't need us. If we disagree on more than 2%, those are the silent failures already in your pipeline." This is the public artifact behind the VEM 1.0 spec.
- Reads
-
OSS → cloud funnel-upsell line on
convertcompletion. A single[dim]-styled line prints to stdout after a successful conversion, pointing to the free tier and the open VEM spec. Suppress withPDFMUX_NO_UPSELL=1. Skipped automatically when stdout isn't a TTY (so it never pollutes piped output) and when writing to stdout via--output -.
tests/test_audit_cli.py— 8 tests covering CSV/JSON inputs, alternate column names, unsupported extensions, no-filename-overlap, empty directory, exit-code contracts, and the documented column set.
Test count: 678 passing (up from 670).
Correctness patch — the bug behind the silent-failure incident that prompted 1.6.1. No defaults change. Every existing flag and CLI invocation behaves identically. Confidence numbers are now correct on documents where they were previously inflated.
-
audit.compute_document_confidencewas returning 1.0 on documents with empty extractions. The function did a content-weighted average of the per-pageconfidencevalue the extractor wrote at yield time — always1.0with the comment "audit will reassess". The reassessment never happened, so blank pages, HTML files renamed to.pdf, single-character bodies, and image-only pages with no OCR all returned document confidence1.0.Fix in
src/pdfmux/audit.py: re-score every page withscore_page(p.text, p.image_count)before averaging, and stop flooring the per-page weight at1(which let blank pages register full weight in the denominator).This is the bug behind the silent failures in the 433-PDF batch retro.
--strict --min-confidence 0.20(shipped in 1.6.1) could not catch the eleven silent failures because the audit didn't know the pages were empty.
-
eval/directory at the project root — a self-contained confidence calibration harness:eval/build_fixtures.pygenerates 50 labeled PDFs from a fixed seed (clean digital, multi-page, table-heavy, Arabic, 0-byte, HTML-as-PDF, heavily/lightly truncated, blank pages, micro-text, image-only-no-OCR).eval/run_eval.pyruns pdfmux on each fixture and writesoutputs/raw_scores.csv.eval/calibrate.pycomputes ROC and recommends thresholds at fixed precision targets (P >= 0.95 for the strict gate, P >= 0.80 for the warning gate).eval/README.mddocuments the workflow and the May 2026 calibration result that drives the 1.7 default.
The eval set was the instrument that surfaced the audit bug — without it, the bug was invisible. The first calibration run produced precision flat at 0.683 across every threshold, which is the smoking gun.
After the audit fix, on the 50-fixture eval set:
| Threshold | Precision | Recall | F1 |
|---|---|---|---|
| 0.50 | 0.849 | 1.000 | 0.918 |
| 0.75 | 1.000 | 1.000 | 1.000 |
0.75 is the recommended default for --min-confidence when 1.7 ships breaking-default-strict on pdfmux convert <dir>. Not enabled by default in this release.
Corrected 2026-07-16: the recall originally reported here (0.71) was an artifact of a broken Arabic test fixture whose generator silently emitted middle-dots instead of Arabic;
build_fixtures.pynow renders real Arabic and self-verifies. Numbers above are the corrected values. This is a small internal regression set, not a benchmark — seeeval/README.md.
Regression-guard release. No code behavior changes. Adds 11 contract tests for the real-world failure modes seen in the 433-PDF batch run that prompted 1.6.1.
tests/test_real_world_failures.py— 11 behavioral-contract tests covering:- Truncated PDFs (the four
pypdf: Stream has ended unexpectedlycases from the v1 batch). Pdfmux must either recover or raise — never silently return empty Markdown. - Non-ASCII filenames (CJK + full-width punctuation, e.g.
(原版)).extract_textandbatch_extractmust accept these without shell-quoting issues. - Arabic-only PDFs. The BiDi pipeline must not crash on RTL text.
- 0-byte files. Must raise a named
PdfmuxError, never silently return empty. - HTML files renamed to
.pdf. Must error cleanly OR return text without HTML markup — never pass through<html>...</html>as if it were content. - Missing files. Must raise
FileError, not bareFileNotFoundError. - Batch isolation. A bad file in
batch_extractmust yield an exception for that file without poisoning the rest of the batch.
- Truncated PDFs (the four
Test count: 670 passing (up from 659).
Field-driven patch release. Triggered by a real-world 433-PDF batch run where the first invocation silently dropped 16 documents — the exact failure mode pdfmux's brand promises to prevent. All changes are additive; no breaking defaults. The retro and full plan are at https://github.com/NameetP/pdfmux/blob/main/CHANGELOG.md and the corresponding blog post.
pdfmux convert --strict --min-confidence FLOAT— exits with code3if any document confidence falls below the threshold. Exit codes are now documented:0ok,1runtime error,2usage error,3strict gate failed.- stderr WARNING line for every document with confidence < 0.50, regardless of
--strict. Makes silent low-quality batches visible in CI logs. manifest.jsonwritten to the output directory at the end of every batchconvert <dir>run. Includes per-document confidence, extractor used, OCR pages, cost, warnings, and a confidence breakdown (high ≥0.80 / medium 0.50–0.80 / low <0.50). Schema v1.0.pdfmux.batch_extract(paths, **kwargs)— public Python API overprocess_batch. Use this instead of shelling out topdfmux convertin a loop.pdfmux doctor --check <dir>— samples PDFs from a directory, classifies them, and recommends missing extras. Catches "23% of your batch is scanned, installpdfmux[ocr]" before you waste a batch run.- RapidOCR warnings translated into pdfmux-namespaced INFO messages with file + page context. The bare
[RapidOCR] main.py:132: The text detection result is emptylines are gone.
pdfmux.ml_headingsandmodels/heading_classifier.pkl. The ML heading classifier requiredsklearn(not a base dep), printedFailed to load ML heading model24+ times per batch, and produced no measurable lift over the heuristic font-size fallback. Net: -250 LOC, no behavior change on real-world PDFs.
pdfmux.__version__was stale at1.5.1; now matchespyproject.toml.
- README leads Python users with
batch_extractfor batch use cases. pdfmux[ocr]promoted from "optional extra" to recommended-default for any real batch.- Note added: don't wrap pdfmux with your own pypdf/pdfplumber fallback — PyMuPDF tolerates malformed PDFs that pypdf rejects.
- Mistral OCR as a paid extraction backend ($0.002/page, 96.6% table accuracy on internal benches). Optional dep:
pdfmux[llm-mistral]. - Marker neural extractor (
pdfmux[marker]) — strong on academic papers and dense layouts. Models cached as module-level singletons to amortize warm-up. - Gemma 3 27B IT as a vision LLM provider via the GeminiAPI OpenAI-compat endpoint. Reuses
GEMINI_API_KEY. Native Arabic OCR. (Published as "Gemma 4 27B IT" at the time; corrected 2026-07-27 — see the Unreleased entry. Gemma 4 has no 27B size.)
- BiDi post-processing for Arabic and Hebrew using
python-bidi. Markdown-aware: preserves heading prefixes, list markers, and code fences while reordering RTL text. - Arabic detection in the document classifier (samples first 20 pages). New
arabicpage type wired into the routing matrix → Gemma fallback chain. has_arabicflag onDocumentResult.
- Smart result cache keyed by
(file_hash, quality, format, schema)at~/.cache/pdfmux/results/. 30-day TTL, 1 GB max, LRU eviction. New--no-cacheand--clear-cacheflags. - Streaming extraction as NDJSON events (
classified→page→warning→complete). Newpdfmux streamCLI command andextract_streamingMCP tool.
- Configuration profiles at
~/.config/pdfmux/profiles.yaml. Built-ins:invoices,receipts,papers,contracts,bulk-rag. Newpdfmux profiles list/show/save/deletesub-commands and--profileflag onconvert. - Watch mode:
pdfmux watch <dir>auto-converts new and changed PDFs (viawatchdog). - Cost estimation:
pdfmux estimate <pdf>predicts cost before running. - Diff command:
pdfmux diff a.pdf b.pdfproduces a Levenshtein-style comparison of two extractions. - Better error messages — every
PDFMuxErrorcarries.user_message,.suggestion, and a copy-pasteable.reproduce_cmd. - Retry with backoff —
@with_retry(max_attempts=3, backoff_base=2.0)decorator applied to every LLM provider'sextract_page(). HonorsRetry-Afterheaders.
- 9 new test modules. 659 tests passing (3 skipped) — up from 481.
GEO metadata refresh: keywords, description, README opener tuned for AI-engine surfacing. No code changes.
- MCP Registry ownership marker (
mcp-name: io.github.NameetP/pdfmux) in README so the package can be claimed on registry.modelcontextprotocol.io. Unblocks aggregator ingest across PulseMCP and other downstream MCP directories.
- Metadata-only release. No runtime, API, or benchmark changes vs. 1.5.0.
- Benchmark score: 0.900 -> 0.905 (+0.5%) on opendataloader-bench. Now within 0.4% of the paid #1.
- Table score (TEDS): 0.887 -> 0.911 (+2.7%) — image table OCR extracts tables embedded as images using RapidOCR with spatial clustering for row/column reconstruction.
- Heading score (MHS): 0.844 -> 0.852 (+0.9%) — ML heading classifier (sklearn GradientBoosting, 212KB) as fallback when font-size heuristics find nothing. Consecutive heading merge ("# III." + "# Regulatory..." → "# III. Regulatory..."). Digit page-number filter using y-position.
- Reading order (NID): 0.918 -> 0.920 — minor improvements from heading cleanup.
- 98 docs improved, 3 regressed across the 200-doc benchmark.
src/pdfmux/image_table_ocr.py— OCR-based table extraction from image regions. Renders image at 300 DPI, runs RapidOCR, clusters text boxes into rows/columns, outputs markdown pipe table. Safety filters: 50%+ fill rate, 50%+ numeric density.src/pdfmux/ml_headings.py— ML heading classifier using sklearn GradientBoosting on 12 font features. Used as fallback when heuristic detection finds zero headings.src/pdfmux/models/heading_classifier.pkl— trained model (212KB, doc-level cross-validated).
- OpenDataLoader extractor — integrate OpenDataLoader-PDF (by Hancom) as a backend extractor. Best-in-class reading order (0.94 NID) and table accuracy (0.93 TEDS). Auto-selected in standard mode when installed. Install with
pip install pdfmux[opendataloader]. Requires Java 11+. - New
pdfmux doctorcheck for OpenDataLoader availability. - New
pdfmux benchrow for OpenDataLoader comparison. - pdfmux now acts as an orchestrator — automatically routes to the best available extractor per document type rather than being a single extraction engine.
- Benchmark score: 0.867 -> 0.900 (+3.7%) on opendataloader-bench (200 real-world PDFs). Now #2 overall, beating docling (0.877) and every other open-source extractor.
- Heading detection overhaul — TOC page cleanup strips false headings from Contents pages. Sentence-ending filter prevents body text promotion. Soft heading fallback for docs with subtle font-size signals. Equation and running-header filters. Page-number filter.
- Heading score (MHS): 0.739 -> 0.844 (+14.2%) — beats all extractors including paid tiers.
- Reading order (NID): 0.910 -> 0.918 — smart quote normalization, italic/link stripping, accent normalization, Unicode cleanup.
- Table score (TEDS): 0.884 -> 0.887 — relaxed table block threshold.
- Postprocess hardening — normalize smart quotes/dashes to ASCII, strip markdown links and footnote markers, strip italic underscores, remove zero-width chars, normalize heading levels to H1.
- Bold-line promotion now validates candidates (rejects sentences, figure captions).
- 95 docs improved, 2 regressed across the 200-doc benchmark.
Structured extraction, parallel OCR, Docling table overlay.
- LangChain loader (
integrations/langchain.py) —PDFMuxLoader(path, quality)wrapsload_llm_context(), returnslist[Document]with metadata. Lazy import of langchain-core. - LlamaIndex reader (
integrations/llamaindex.py) —PDFMuxReader(quality)wrapsload_llm_context(), returnslist[Document]. Lazy import of llama-index-core. - Security hardening — file size limit (500MB), page count limit (10,000), configurable via
PDFMUX_MAX_FILE_SIZE_MBandPDFMUX_MAX_PAGESenv vars. - API stability tests — signature-level tests lock function parameters, JSON schema fields, and
__all__exports for the 1.x series. - New optional extras:
pdfmux[langchain],pdfmux[llamaindex], updatedpdfmux[all]. - 19 new tests: API stability (10 tests), integrations (6 tests), security (3 tests).
- Interface locked — public API signatures frozen for 1.x (additive-only keyword args allowed). JSON schema frozen at 1.0.0.
- Classifier upgraded to "Production/Stable".
- JSON schema version bumped to
1.0.0. - Total test count: 151.
- Region OCR (
regions.py) — surgical OCR of image regions within pages.detect_weak_regions()finds images without overlapping text.ocr_region()clips and OCRs just the image area.merge_region_text()inserts results at correct y-position. "Bad" pages now preserve good text while recovering image content. - Enhanced MCP server — 2 new tools:
analyze_pdf(quick triage — classify + audit without extraction) andbatch_convert(process entire directories). Error responses now include structured error codes. WeakRegiontype (types.py) — frozen dataclass withpage_num,bbox,reason. Exported frompdfmux.- Example scripts (
examples/) —basic_usage.py,batch_processing.py,mcp_agent.pywith self-contained usage examples. - 14 new tests: region OCR (8 tests) and enhanced MCP (6 tests).
- Multi-pass pipeline now tries region OCR on "bad" pages before falling back to full-page OCR.
- JSON schema version bumped to
0.9.0. - Total test count: 132.
- Column detection (
detect.py) —detect_layout(page)detects multi-column PDFs by clustering text block x-positions with gap detection. ReturnsPageLayoutwith column count, boundaries, and reading order. - Layout-aware extraction (
fast.py) —_needs_reorder()samples first 5 pages; if multi-column detected,_extract_with_layout()reorders blocks column-by-column. Single-column PDFs take existing fast path with zero overhead. - Block-level scoring (
audit.py) —score_block(text)applies 3 lightweight quality checks (alphabetic ratio, word structure, encoding quality) at individual text block granularity. PageLayouttype (types.py) — frozen dataclass withcolumns,column_boundaries,reading_order. Exported frompdfmux.- 8 new tests: layout detection (5 tests) and block scoring (3 tests).
- JSON schema version bumped to
0.8.0. - Total test count: 118.
- Structured error codes — every exception now has a
.codeclass attribute (PDF_NOT_FOUND,PDF_CORRUPTED,EXTRACTION_ERROR,PARTIAL_EXTRACTION,NO_EXTRACTOR,FORMAT_ERROR,AUDIT_ERROR,OCR_TIMEOUT). Backward-compatible: existing catch blocks still work. OCRTimeoutError— new exception for OCR timeout scenarios. Exported frompdfmux.- Provenance on chunks —
Chunkdataclass now carriesextractorandocr_appliedfields. Propagated throughchunk_by_sections()andformat_llm()output. - JSON
error_codefield — JSON output includeserror_code(null on success). Per-pageocrboolean flag in pages array. - CLI logging —
--verbose(INFO),--debug(DEBUG),--quiet(ERROR only) flags onpdfmux convert. - 17 new tests: error codes (13 tests) and provenance (4 tests).
FileErrorandExtractionErroraccept optionalcode=keyword for specific error codes.- LLM format output now includes
extractorandocr_appliedper chunk. - JSON schema version bumped to
0.7.0. - Total test count: 110.
- Parallel OCR dispatch (
parallel.py) — OCR re-extraction now runs across 4 threads viaThreadPoolExecutor. ONNX runtime releases the GIL during inference, giving real parallelism. Per-page timing and error isolation viaPageOCRResultfrozen dataclass. - OCR budget control — Standard mode caps OCR at 30% of document pages. Prioritizes "bad" pages (some text) over "empty" pages.
quality=highignores the budget. Override withPDFMUX_OCR_BUDGETenv var. - Windowed audit —
audit_document()now processes pages in windows of 50 instead of loading the entire document at once. Bounds memory on 500+ page PDFs. - 7 new tests: parallel OCR dispatch (4 tests) and budget control logic (3 tests).
- Multi-pass pipeline uses parallel OCR dispatch instead of serial page-by-page loop.
- JSON schema version bumped to
0.6.0. - Total test count: 93.
- Typed architecture — 6 frozen dataclasses and enums in
types.py(Quality,OutputFormat,PageQuality,PageResult,DocumentResult,Chunk). Every data flow in the pipeline now passes through typed, immutable objects. - Error hierarchy — flat exception tree in
errors.py:PdfmuxErrorbase withFileError,ExtractionError,ExtractorNotAvailable,FormatError,AuditError. All exported fromimport pdfmux. - Streaming extractors — all 5 extractors yield
Iterator[PageResult], one page at a time. Memory stays bounded even on 500-page PDFs (~135MB peak vs unbounded before). - Extractor protocol + registry —
ExtractorProtocol class +@register(name, priority)decorator for auto-registration. Programmatic access viaget_extractor(),available_extractors(),extractor_names(). - 5-check confidence scoring — per-page confidence computed from character density, alphabetic ratio, word structure, whitespace sanity, and encoding quality (mojibake detection). Content-weighted document average replaces the old heuristic scorer.
- Concurrent batch processing —
process_batch()withThreadPoolExecutor. Error isolation per file — one failure doesn't stop the batch. Used by CLI batch conversion.
- JSON schema version bumped to
0.5.0. - All public types and errors exported from the top-level
pdfmuxpackage (pdfmux.PageResult,pdfmux.FileError, etc.). - Extractors conform to a common
Extractorprotocol and register via decorator instead of hardcoded lookup. - Confidence scoring is now deterministic and auditable — 5 named checks with individual scores, content-weighted aggregation.
- Extractor names simplified:
"pymupdf4llm (fast)"→"pymupdf4llm","docling (tables)"→"docling", etc. detect.pynow raisesFileErrorinstead ofFileNotFoundError/ValueErrorfor consistency with the error hierarchy.
- Memory usage no longer scales linearly with page count during extraction (streaming architecture).
- Public Python API — three importable functions:
extract_text(),extract_json(),load_llm_context(). No more CLI-only usage. - Section-aware chunking (
chunking.py) — splits Markdown at heading boundaries with per-chunk page tracking and token estimates (chars/4). Powersload_llm_context()and--format llm. - LLM output format —
pdfmux report.pdf -f llmoutputs chunked JSON with{title, text, page_start, page_end, tokens, confidence}per section. Designed for RAG pipelines and context windows. pdfmux analyze— per-page extraction breakdown showing page type (digital/graphical/scanned), quality (good/bad/empty), char count, confidence, and extractor used.- Locked JSON schema — JSON output now includes
schema_version: "0.4.0"andocr_pagesfield for downstream stability.
- JSON output now includes
schema_versionandocr_pagesfields in every response. --formatoption acceptsllmin addition tomarkdown,json,csv.
- Multi-pass extraction — fast extract → per-page audit → selective OCR → merge. All standard-mode PDFs now go through this pipeline. Zero overhead when all pages are good.
- RapidOCR extractor — lightweight OCR using PaddleOCR v4 models via ONNX runtime. ~200MB install, CPU-only, Apache 2.0 license. Replaces Surya as default
pdfmux[ocr]. - Per-page quality auditing (
audit.py) — classifies each page as "good", "bad", or "empty" based on text density and image presence. Drives selective re-extraction. - Smart OCR comparison — for "bad" pages (some text), only uses OCR if it extracts MORE text than fast extraction. For "empty" pages, any OCR text >10 chars is accepted.
- OCR fallback chain — RapidOCR → Surya → Gemini Flash LLM. Each step only processes pages the previous step couldn't recover.
ocr_pagestracking —ConversionResultnow reports which pages were re-extracted with OCR.- Multi-pass in bench —
pdfmux benchnow includes a "Multi-pass" row showing the full pipeline result. - RapidOCR in doctor —
pdfmux doctornow checks for RapidOCR installation.
pdfmux[ocr]now installs RapidOCR + onnxruntime (~200MB) instead of Surya (~5GB). Surya moved topdfmux[ocr-heavy].- Routing simplified — removed
_handle_graphical_pdf()and_handle_mixed_pdf(). Multi-pass handles all PDF types uniformly. - Graphical + tables routing — graphical PDFs no longer route to Docling even if table heuristics trigger. Multi-pass OCR is more valuable than table formatting for image-heavy content.
- Confidence scoring — OCR-recovered pages get a small penalty for OCR noise (max 15%) instead of the large "extraction_limited" penalty.
- Pitch decks and slide exports now get 70-85% confidence with OCR installed (was 30-55%)
- Digital PDFs maintain identical confidence and zero overhead through multi-pass fast path
- RapidOCR logging noise suppressed (model paths, engine info no longer pollute output)
- Graphical PDF detection — detects image-heavy PDFs (pitch decks, infographics) and routes to OCR/LLM instead of fast extraction
- Honest confidence scoring — confidence now reflects actual extraction quality, not just text presence. Graphical PDFs with missing image content score lower.
- Actionable warnings — clear messages when extraction is limited, with specific
pip install pdfmux[ocr]orpdfmux[llm]suggestions - MCP server quality metadata — AI agents now receive confidence score and warnings alongside extracted text
- Spaced-text cleanup — fixes common PDF artifact where text renders as "W i t h o v e r" → "With over"
- Detection no longer classifies image-heavy "digital" PDFs as fully extractable
- Confidence no longer reports 100% on graphical PDFs where image content was missed
- FastExtractor now falls back to raw fitz when pymupdf4llm returns empty (fixes certain PDF encodings)
- bench command now shows honest confidence that matches pipeline routing
pdfmux doctor— check installed extractors, versions, and API keyspdfmux bench— benchmark all available extractors on a PDF side by side
- Suppressed upstream pymupdf4llm "Consider using pymupdf_layout" noise from all commands
First public release.
- Smart routing — auto-detect PDF type and pick the best extractor
- PyMuPDF extractor — digital PDFs at 0.01s/page
- Docling extractor — 97.9% table accuracy (optional:
pdfmux[tables]) - Surya OCR extractor — scanned PDF support (optional:
pdfmux[ocr]) - Gemini Flash extractor — complex layout fallback (optional:
pdfmux[llm]) - Mixed PDF handling — digital pages + scanned pages merged automatically
- Output formats — Markdown, JSON, CSV
- Quality presets — fast, standard, high
- Batch conversion — convert entire directories
- MCP server — built-in stdio server for AI agents
- Confidence scoring — text completeness, encoding quality, structure checks
- Graceful fallback — missing extractors fall back silently to next best