PDF Parsing Optimizations
Between releases 1.1.0 and 1.1.1 a series of profile-driven changes reduced the corpus benchmark from 21.4 minutes to 6.7 minutes — a 3.2x improvement, with the worst-case single file dropping from 2.1 min to 11.65 s (10.7x). This page documents the methodology, the contributing changes, and the work that remains.
Methodology
The work followed a tight measure / fix / re-measure loop with three complementary tools shipped with the repo:
Corpus benchmark (
benchmarks/corpus/run.py) — pulls a deterministic sample from public corpora (arxiv, govdocs1, Apache POI, Enron), times conversion of each doc, and produces stratified p50 / p95 / mean tables per source and per format. See Performance Tuning for a full reference.Single-file profiler — for the slowest doc in each report, an isolated
cProfilerun dumps cumulative-time and self-time call graphs to attribute wall-clock cost to specific functions.Inspect helper (
benchmarks/corpus/inspect) — renders Markdown alongside its source PDF for the slowest / largest / random subsets. Timing tells you whether a doc is fast; only reading the markdown tells you whether it is correct. Inspect was decisive several times during this work for spotting pathologies that pure timing would have missed (TOC dot-leader tables, empty 2-column tables,![Image from page N]()noise).
The corpus benchmark detects regressions; the profiler attributes them; the inspect helper verifies correctness. All three were needed.
Headline impact
Corpus-wide on a single 149-doc run:
Metric |
Baseline (b0e4224) |
Optimized (3516bc9) |
Improvement |
|---|---|---|---|
Total wall time |
21.4 min |
6.7 min |
3.2x faster |
Aggregate MB/s |
0.08 |
0.37 |
4.6x |
PDF p50 |
8.54 s |
728 ms |
11.7x faster |
PDF p95 |
1.1 min |
13.5 s |
4.9x |
PDF mean |
16.0 s |
5.0 s |
3.2x |
govdocs1 p50 |
5.91 s |
194 ms |
30x faster |
govdocs1 mean |
13.7 s |
1.08 s |
12.7x |
The two underlying reports — b0e4224-baseline.md/json and
3516bc9-optimized.md/json — are committed under benchmarks/reference/
for verification.
Case study: the file that drove the investigation
The slowest individual doc in the committed baseline run
(b0e4224-baseline) was govdocs1/000887.pdf, a 1.31 MB / ~150-page
government report on renewable energy, at 124.96 s.
To attribute where that time went, we re-ran the single file under cProfile.
Profiling instrumentation inflates wall time substantially — the profiled run
clocks ~322 s against the ~125 s benchmark time — but the proportions are what
matter. cProfile attribution of that instrumented conversion:
Bucket |
Time (cumulative) |
% of total |
|---|---|---|
PyMuPDF |
202 s |
63% |
|
~110 s |
34% |
Plain text extraction ( |
26 s |
8% |
all2md code total (parser + render + AST) |
~15 s |
5% |
Two heavyweight passes consumed ~97% of runtime, and neither was our code.
find_tables() was running on every page — including the ~100 pages of pure
prose where it would find no tables but still scan 13.5 million character /
rect-containment relationships. The optional pymupdf-layout ONNX-based
block classifier was likewise running on every page, including pages whose
layout is trivially single-column prose.
In the committed optimized run (3516bc9-optimized) the same file converts in
11.65 s — a 10.7x speedup over its 124.96 s baseline, and the worst-case
single-file number quoted in the headline table above.
The contributing changes
In order of impact:
find_tables() pre-flight gate (3516bc9)
The big one. Added page_has_table_signals() in _pdf_tables.py: a cheap
scan of page.get_drawings() that returns True only when the page has
ruling-line drawings or a sufficiently large closed rectangle. The default
“both” mode in _detect_page_tables now consults it before calling
find_tables() and skips both the PyMuPDF call and the ruling-line fallback
when there are no table indicators.
mode == "pymupdf" is unchanged — users who explicitly opted into always-on
PyMuPDF table detection keep that behavior.
The gate is conservative on error: any failure to enumerate drawings returns
True so PyMuPDF quirks can’t silently lose real tables. Cascading effect
on this case study:
find_tables()calls: 171 → 112 (35% of pages skipped)find_tables()cumulative time: 202 s → 102 schar_in_bboxcalls: 13.5 M → 9.3 MLayout model time also dropped (~110 s → ~61 s) because PyMuPDF’s
find_tables()internally invokes layout analysis to position tables — skipping the call avoided the layout invocations it would have triggered.
Those deltas are measured under the profiler (which is why they track the ~322 s instrumented baseline, not the 125 s benchmark time). End to end, the committed benchmark time for this file fell from 124.96 s to 11.65 s across the two changes.
Benchmark layout-model toggle (3516bc9)
layout_analysis_mode ("auto" / "enabled" / "disabled") already
existed on PdfOptions. The change here was on the benchmark side: the
corpus harness now defaults the mode to "disabled" so wall-clock numbers
are reproducible across machines that may or may not have pymupdf-layout
installed. Pass --use-layout-model to opt back into the library default
("auto") for runs that want layout-on numbers.
The library default is unchanged. End users who installed pymupdf-layout
deliberately still get its semantic block classification (title /
section-header / caption / footnote / picture / formula) by default.
Pathological table rejection (cbd5de7)
PyMuPDF’s find_tables() and the ruling-line fallback both occasionally
fire on non-tabular content: decorative frames, oversized empty grids,
dot-leader-heavy TOC regions, and section-header underlines. Shared guards
on row/column counts (MAX_TABLE_COLS = 25, MAX_TABLE_ROWS = 200),
empty-cell ratios (MAX_TABLE_EMPTY_RATIO = 0.70), and dot-leader
density (MAX_DOT_LEADER_CELL_RATIO = 0.30) now reject these in both code
paths.
The guards eliminated hundreds-to-thousands of garbage “table” rows per file in the inspect output — a correctness win that also reduces downstream rendering work.
Skip image work in alt_text mode (10227a6)
In the default attachment_mode="alt_text", image extraction has no URL
to point markers at, so it was emitting ![Image from page N]()
placeholders — pure noise. An image-heavy 32-page workshop PDF produced 130+
such lines. extract_page_images() now returns early in that mode, which
both suppresses the markers and avoids decoding every pixmap only to throw
the bytes away (~160 pixmap decodes avoided on the workshop PDF).
image_placement_markers remains meaningful in save and base64
modes where there is a real URL / path for the marker to reference.
Rotated text grouping (f47874c)
Previously each rotated PDF line emitted its own paragraph with an inline
*[rotated 90° counter-clockwise]* marker, flooding output (~280 markers
on the figure-axis labels of the “Attention Is All You Need” paper).
Consecutive rotated spans now accumulate within blocks and merge across
blocks via metadata. The annotation is opt-in via the new
annotate_rotated_text option (default False).
Heading & whitespace robustness (b0e4224)
Earlier in the cycle: reworks the PDF heading classifier to handle the
body=11pt / header=12pt convention (the prior 1.2 size-ratio default
produced an empty header_id), enforces style requirements that bold-only
header sizes were silently ignoring, and classifies lines by aggregated span
style rather than spans[0] only. Also filters tiny / page-header images
as ghost markers, collapses long whitespace runs that PDF spans use as
layout padding, and merges split numbering-prefix headings (“I.” +
“Background” → “I. Background”) that the layout model emitted as two
headings.
New options from this change: min_image_dimension,
filter_header_footer_images, collapse_excess_whitespace,
dedup_running_headings.
What’s still slow
The optimizations don’t fix every slow case — and the remaining hot spots are worth naming honestly:
Scientific-PDF table fragmentation. PDFs with many real tables (e.g., benchmark comparison tables across LLMs in the
2605.13841v1arxiv paper) still take 1-2 minutes.find_tables()correctly identifies ruling lines on those pages, so the gate doesn’t help. But PyMuPDF then fragments one logical table into many small sub-tables and mangles multi-line headers across rows. Future work: an adjacent-table merging pass to recombine fragments.Multi-page single paragraphs. Some PDFs (notably
000359.pdf,000762.pdf, and000887.pdf) emit single Markdown paragraphs of 3,000-5,000 characters where text spans multiple pages without clear break signals. This is mostly a correctness issue but may also amplify downstream rendering cost.Per-page layout model invocations on long arxiv papers. Even with the gate, papers with real tables on most pages pay the
pymupdf-layoutcost per page. The current model runs the full ONNX graph regardless of page complexity; a heuristic to skip layout classification on pages with trivially single-column prose would help long-paper conversion.TOC dot-leader pseudo-tables.
000887.pdfstill has 1,431 “table” rows in its output, most from TOC pages where the dot-leader guard isn’t firing on PyMuPDF’s multi-line cell format. The dot-leader regex_DOT_LEADER_TAILmatches in isolation but evidently isn’t being applied tofind_tables()cell content. Bug to investigate.
Reproducing these numbers
Two committed reference reports under benchmarks/reference/ capture the
before / after state:
File |
State |
|---|---|
|
2026-05-06, 21.4 min total. Code includes the heading-detection
rework but predates the table guards, image alt-text fix, and the
|
|
2026-05-15, 6.7 min total. Code includes the full v1.1.1 perf work. |
Both contain per-doc timings in their .json form for diffing.
Wall-clock numbers depend on hardware and load — the ratios between runs are far more meaningful than absolute seconds. To verify on a clean runner, dispatch the corpus benchmark GitHub Action:
gh workflow run benchmark.yml # against current main
gh workflow run benchmark.yml --ref <sha> # against a specific commit
The workflow runs on a fresh ubuntu-latest VM, caches the ~1 GB corpus
between runs, and uploads the full results JSON + Markdown report as a
90-day artifact.
See Also
Performance Tuning - General performance tuning guide (caching, parallelism, memory limits, batch processing patterns)
benchmarks/corpus/README.mdin the source tree - the corpus harness configuration referencebenchmarks/reference/README.mdin the source tree - convention for committed historical benchmark snapshots