Python PDF Parser: Choose by Page and Output
Choose a Python PDF parser by the page you have and the output you need: text extraction for prose, cell arrays for records, layout-aware Markdown for columns, and OCR for image text. The page cases are plain prose, columns, tables, scans or mixed pages, and equations. pdfplumber, MarkItDown, PyMuPDF4LLM, and the hosted Markovo path cover different parts of that map. None of the tested defaults resolved every case. 2
Classify pages before choosing a Python PDF parser
Select the output contract before picking a package: readable sentences, usable cell records, and document Markdown require different evidence of success.
| Page feature | How to identify the case | Route and acceptance condition |
|---|---|---|
| Plain prose | Copy a paragraph into an editor and compare where each sentence continues | Text extraction is enough when sentences remain complete and in order |
| Columns | Copy across a column boundary and look for an unrelated sentence inserted mid-thought | Use layout-aware extraction when left and right paragraphs alternate |
| Tables | Follow a value upward to its leaf heading and any heading spanning several columns | Keep cell arrays for records. Accept Markdown only if those relationships survive |
| Scans or mixed pages | Compare copied text from an ordinary page with a page whose words are visible only in an image | Add OCR for missing image text. Accept the document only when the scanned page contributes its text |
| Equations | Compare the extracted expression with the displayed operators, exponents, and subscripts | Preserve native text where it works. Use separate formula handling when symbols or their relationships change |
A PDF can occupy several rows of this table. The mixed document in our test retained its ordinary prose and table while losing the scanned page in the local default invocations. 2
This guide concerns content extraction into Python workflows, rather than PDF object inspection or page editing. For search indexing, readable paragraph text can be sufficient. For invoice processing, the application needs a quantity attached to the right item and heading.
Use pdfplumber for text or explicit cell arrays
Use pdfplumber when you want inspectable text or cell arrays, then map table headings explicitly before treating the result as records.
The pdfplumber documentation distinguishes text extraction from table extraction. The following call writes text and prints the table arrays separately. Both operations ran on the sample PDF used for this guide. 12
import json
from pathlib import Path
import pdfplumber
with pdfplumber.open("input.pdf") as pdf:
text = "\n\n".join(page.extract_text() or "" for page in pdf.pages)
tables = [table for page in pdf.pages for table in page.extract_tables()]
Path("output.md").write_text(text, encoding="utf-8")
print(json.dumps(tables, ensure_ascii=False))
Output from our column sample, unedited:
Invoice reconciliation starts with the export file, not Demand forecasts fail upward more often than they
the ledger. Teams that reconcile weekly pull the fail downward. Optimistic sales inputs push safety
payment provider's settlement report first, match it stock up for slow movers, and the excess sits in the
Text extraction joined unrelated columns. The table interface has a different boundary: it returns cells but does not explain the business relationship between heading rows. For display, our table-only companion serialized those arrays as Markdown without filling empty positions. 13
Output from our grouped-heading sample, unedited:
| Item | Qty | Price | |
| --- | --- | --- | --- |
| | | Unit | Total |
The price group and its leaf headings still occupy separate rows. A consumer that assumes the first row contains the final field names will mislabel the price columns. For this invoice, map the leaf headings to the price group before loading records.
Empty positions do not mean “repeat the preceding label.” The installed table extractor distinguishes a position with no cell rectangle from a cell rectangle containing no extracted characters. It returns None for the former and an empty string for the latter. Our grouped-heading arrays contained empty strings, so this sample does not demonstrate the absent-cell-rectangle branch. Keep the raw arrays if that distinction affects header mapping. 3
| Failure condition | Visible output | Action |
|---|---|---|
| Multiple text columns | Unrelated phrases share a line | Switch to a layout-aware path |
| Grouped or rotated headings | Values remain, but a usable leaf-heading row is missing | Define the header mapping from the source table |
| Image-only page | No extracted page text | Supply an OCR stage |
Use MarkItDown for straightforward document Markdown
Use MarkItDown for a direct document-to-Markdown path when ordinary text and simple tables are sufficient, with separate checks for columns and scans.
The MarkItDown Python interface returns the converted text without requiring your own table serializer. This invocation disables optional plugins and ran on the sample PDF used for this guide. 4
from pathlib import Path
from markitdown import MarkItDown
result = MarkItDown(enable_plugins=False).convert("input.pdf")
Path("output.md").write_text(result.text_content, encoding="utf-8")
The simple grid became a pipe table with its heading and data rows intact. The column sample was different: only two complete reference sentences survived. The output alternates between unrelated subjects before either sentence finishes. 2
Output from our column sample, unedited:
Invoice reconciliation starts with the export file, not
Demand forecasts fail upward more often than they
the ledger. Teams that reconcile weekly pull the
The installed converter attempts form-style extraction before falling back to pdfminer. Our ordinary and grouped tables triggered that form-style branch. The column document did not, so it took the whole-document fallback. 512
Optional OCR integrations fall outside the plugin-disabled invocation. 4
| Failure condition | Visible output | Action |
|---|---|---|
| Columns reach the text fallback | Sentence fragments alternate between unrelated paragraphs | Use layout-aware extraction |
| A heading spans price columns | Group label and leaf labels occupy different rows | Normalize the header before consuming values |
| Scanned page with this configuration | The scan contributes no text | Add a configured recognition path |
Use PyMuPDF4LLM when column order matters
Use PyMuPDF4LLM for layout-aware Markdown when column order matters, while treating table meaning and image text as separate requirements.
The PyMuPDF4LLM API exposes a direct Markdown conversion call. This call ran on the sample PDF used for this guide and across our page cases. In our column document, it retained the reference sentences and paragraph order that the text-oriented paths disrupted. 62
from pathlib import Path
import pymupdf4llm
markdown = pymupdf4llm.to_markdown("input.pdf")
Path("output.md").write_text(markdown, encoding="utf-8")
Layout recovery did not produce a ready-made schema for the grouped invoice. The output preserved the visible heading fragments, but the leaf labels remained below the Markdown header separator. 2
Output from our grouped-heading sample, unedited:
# **Invoice with grouped price columns**
|**Item**|**Qty**|**Price**||
|---|---|---|---|
|||**Unit**|**Total**|
The default call also left image text absent in our scan and mixed document. That observation describes this call, rather than every OCR configuration the package supports. A separate issue describes lost scanned content on a page that also contains native text, using a forced-OCR text call. We did not reproduce that issue or test its configuration. 27
| Failure condition | Visible output | Action |
|---|---|---|
| Grouped or rotated table headings | The values survive without a usable header row | Retain source context and map headers explicitly |
| Image text under this default call | Text elsewhere survives, but image words are absent | Configure and evaluate OCR separately |
| Equation extraction | An expression is missing despite readable surrounding prose | Compare the expression with the original before using it |
Use Markovo for a hosted scan path
Use Markovo as a hosted path when image text needs recognition, while keeping table structure and formula recovery explicit in the consuming application.
The stored API download included the scanned text that our local default invocations omitted. The PDF-to-Markdown conversion route is relevant when your Python application needs that hosted document output. The saved responses report OCR work, but their page counters disagree, so they do not establish exact per-page routing. 210
The API flow uses the Python standard library: submit the file, poll its job until it succeeds, then download Markdown. Supply your API key and the required ceiling through environment variables. The example follows the repository's API contract snapshot and was checked with a local transport stub, not a new API submission. 9
import json
import os
import time
import uuid
from pathlib import Path
from urllib.request import Request, urlopen
base = "https://markovo.net"
headers = {"Authorization": "Bearer " + os.environ["MARKOVO_API_KEY"]}
boundary = uuid.uuid4().hex
fields = {"capability_id": "pdf-to-markdown", "mode": "fast",
"max_credit_units": str(int(os.environ["MARKOVO_MAX_CREDIT_UNITS"]))}
parts = []
for name, value in fields.items():
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode())
parts.append(f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="input.pdf"\r\nContent-Type: application/pdf\r\n\r\n'.encode()
+ Path("input.pdf").read_bytes() + b"\r\n")
body = b"".join(parts) + f"--{boundary}--\r\n".encode()
request = Request(base + "/v1/convert", data=body, method="POST",
headers={**headers, "Content-Type": "multipart/form-data; boundary=" + boundary})
with urlopen(request, timeout=60) as response:
submitted = json.load(response)
job_id = submitted["jobs"][0]["job_id"]
deadline = time.monotonic() + 600
while time.monotonic() < deadline:
with urlopen(Request(base + "/v1/jobs/" + job_id, headers=headers), timeout=60) as response:
payload = json.load(response)
job = payload.get("job", payload)
if job["status"] == "succeeded":
break
if job["status"] in {"failed", "cancelled"}:
raise RuntimeError(job)
time.sleep(2)
else:
raise TimeoutError(job_id)
with urlopen(Request(base + "/v1/jobs/" + job_id + "/download?format=md", headers=headers), timeout=60) as response:
Path("output.md").write_bytes(response.read())
Do not automatically repeat a submission after an unknown response. The documented estimate-and-confirm flow provides replay behavior for automation that needs retry-safe confirmation. The CLI was not executed for this guide. 9
Recognized text did not guarantee usable equations. The stored formula output contained broken markup around the summation expression. 11
Output from our equation sample, unedited:
$$
\[\mathsf{s u m}_{\_}\{i=1\}^{\wedge}\{n\}x\textsubscript{_}i\]
$$
The stored tables were HTML rather than pipe tables. Their strings can be present without an explicit relationship between a group label and its leaf headings. Applications that require pipe syntax or calculation-ready records need a separate normalization step. 2
| Failure condition | Visible output | Action |
|---|---|---|
| Consumer accepts only pipe tables | The download contains HTML table tags | Parse HTML cells or convert the table representation |
| Heading groups carry meaning | Labels are present without explicit spanning-cell relationships | Build a header mapping from the source |
| Mathematical content matters | Formula markup changes an operator, exponent, or subscript | Preserve the original expression or use a separately evaluated formula path |
For ordinary prose, direct text extraction is sufficient when the sentences remain intact. For records, retain cells and map headings. For alternating columns, use layout-aware Markdown. For missing image words, add OCR. Keep the source alongside extracted tables and equations whenever their relationships determine the answer your application will produce.
How we tested
The results support page-level routing, not a universal parser ranking: the inputs are small synthetic files, the local calls use defaults, and the hosted results are historical.
Markovo is the service we build. It ran through the same corpus and scorer as the other paths; its row is reported in full, including where it scored lower.
Local PDF runs and rescoring took place on 2026-10-27 with Python 3.11.14. The Markovo job timestamps in the supplied responses are 2026-10-06. We reused those downloads, rather than submitting new API jobs. Source versions and route probes are recorded in source-diagnostics.json. 12
Corpus and versions
Each PDF has its own .truth.json file describing expected text or cell strings. These controlled examples do not measure real-world OCR noise, financial correctness, or complex equation semantics. The grouped-heading sample's raw pdfplumber arrays contain empty strings, not None, so only source inspection supports the absent-cell-rectangle distinction discussed above.
| Represented shape | Ground truth | |
|---|---|---|
| prose-single.pdf | Single-column prose and paragraph order | prose-single.truth.json |
| two-column.pdf | Alternating-column reading order | two-column.truth.json |
| table-simple.pdf | Ordinary ruled grid | table-simple.truth.json |
| table-merged-header.pdf | Grouped, layered price headings | table-merged-header.truth.json |
| rotated-header.pdf | Rotated headings with ordinary data rows | rotated-header.truth.json |
| scan-image.pdf | Image-only page without a text layer | scan-image.truth.json |
| mixed.pdf | Native prose, table, and scanned pages in one document | mixed.truth.json |
| equation.pdf | Native-text linear expressions, not complex typeset mathematics | equation.truth.json |
| Path or dependency | Version and configuration |
|---|---|
| pdfplumber, text | 0.11.10, page.extract_text() defaults |
| pdfplumber, table-only companion | 0.11.10, page.extract_tables() defaults, no forward-filled headings |
| MarkItDown | 0.1.8, enable_plugins=False |
| pdfminer.six | 20260107, installed dependency used by text conversion |
| PyMuPDF4LLM | 1.28.2, to_markdown() defaults |
| PyMuPDF / pymupdf-layout | 1.28.2 / 1.28.2 |
| Markovo | Stored public-API jobs, standard PDF conversion. No engine build was exposed. The manifest's 2026-09-19.v3-media-unified label matches the response rate-card version, so it is not treated as an engine version |
A historical issue reported flattened native tables in an older release. Our installed release produced a table on our simple grid. We did not obtain or reproduce the reporter's attachment, so our result is limited to our input and configuration. 14
We evaluated only these PDF paths. The local table-only companion deliberately discards ordinary prose, so its zero text and page-anchor values are expected scope limits.
Commands and copy-ready examples
From the repository root, the commands used were:
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python tools/lab_run.py --lab runs/2026-10-27-markovo-python-pdf-parser/lab --tools markitdown,pymupdf4llm,pdfplumber --samples prose-single,table-simple,table-merged-header,two-column,scan-image,mixed,equation,rotated-header --heavy-cache '' --markovo-cache ''
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-10-27-markovo-python-pdf-parser/lab/run-tables.py
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python tools/lab_score.py --lab runs/2026-10-27-markovo-python-pdf-parser/lab --out runs/2026-10-27-markovo-python-pdf-parser/lab/scores.json --markdown runs/2026-10-27-markovo-python-pdf-parser/lab/scores.md
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-10-27-markovo-python-pdf-parser/lab/inspect-sources.py
The runner's local conversion calls are preserved in lab-run.py. The additional table serializer is run-tables.py. Logs are local-run.txt and table-run.txt. No packages were installed during this run.
The lightweight snippets were also executed against sample.pdf, with temporary input/output names matching the examples. Their retained outputs are pdfplumber-sample-output.md, markitdown-sample-output.md, and pymupdf4llm-sample-output.md. inspect-sources.py records those checks and an offline HTTP-stub check of the standard-library API code. The API example was not sent to the service, and the CLI was not executed for this guide. 12
Scoring definitions and complete selected-path results
The table below is a direct projection of scores.json, using tools/lab_score.py, without recalculating or rounding its values. n/a means the sample has no applicable truth field. 215
- Prose (
prose_recall): expected sentences containing at least four words, split at period-space boundaries, found after text normalization - Order (
reading_order): adjacent paragraph-anchor pairs found in increasing order, using each paragraph's first six words, with missing anchors counted as failures - Rows (
table_rows_intact): truth rows whose nonempty cell strings coexist on an output line, without testing cell order or column ownership - Header (
table_header_intact): leaf-heading strings found on the same line, without testing grouped-header relationships - Pipe (
table_pipe_detected): a pipe-table row followed by its separator, excluding HTML tables - Pages (
pages_covered): anchors found from the mixed document, rather than a general PDF page count - Scan (
scan_text_recall): scanned-page reference sentences found under the prose rule - Math (
equation_recall): expressions found after removing supported math delimiters, font wrappers, escapes, and whitespace, without symbolic equivalence checking - Seconds (
elapsed_s): the recorded local single-run elapsed time, not a production benchmark
The JSON also retains the older plain-text equation_recall_exact measure. This table uses the common math-normalized measure for every path. 15
| Path / raw output | Sample | Prose | Order | Rows | Header | Pipe | Pages | Scan | Math | Seconds |
|---|---|---|---|---|---|---|---|---|---|---|
| pdfplumber | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0093 |
| pdfplumber | two-column | 0.1111 | 0.8 | n/a | n/a | n/a | n/a | n/a | n/a | 0.017 |
| pdfplumber | table-simple | n/a | n/a | 1.0 | 1.0 | false | n/a | n/a | n/a | 0.004 |
| pdfplumber | table-merged-header | n/a | n/a | 1.0 | 0.0 | false | n/a | n/a | n/a | 0.0044 |
| pdfplumber | rotated-header | n/a | n/a | 1.0 | 0.0 | false | n/a | n/a | n/a | 0.0042 |
| pdfplumber | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.0005 |
| pdfplumber | mixed | 1.0 | n/a | 1.0 | 1.0 | false | 0.6667 | 0.0 | n/a | 0.0137 |
| pdfplumber | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | 0.0039 |
| pdfplumber-tables | prose-single | 0.0 | 0.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0079 |
| pdfplumber-tables | two-column | 0.0 | 0.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0147 |
| pdfplumber-tables | table-simple | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | 0.0044 |
| pdfplumber-tables | table-merged-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | 0.0045 |
| pdfplumber-tables | rotated-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | 0.0043 |
| pdfplumber-tables | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.0005 |
| pdfplumber-tables | mixed | 0.0 | n/a | 1.0 | 1.0 | true | 0.0 | 0.0 | n/a | 0.0124 |
| pdfplumber-tables | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | 0.0034 |
| markitdown | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0201 |
| markitdown | two-column | 0.1111 | 0.8 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0348 |
| markitdown | table-simple | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | 0.0103 |
| markitdown | table-merged-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | 0.0111 |
| markitdown | rotated-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | 0.0111 |
| markitdown | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.0078 |
| markitdown | mixed | 1.0 | n/a | 1.0 | 1.0 | true | 0.6667 | 0.0 | n/a | 0.0225 |
| markitdown | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | 0.0182 |
| pymupdf4llm | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.1329 |
| pymupdf4llm | two-column | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.1486 |
| pymupdf4llm | table-simple | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | 0.1545 |
| pymupdf4llm | table-merged-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | 0.1556 |
| pymupdf4llm | rotated-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | 0.1475 |
| pymupdf4llm | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.191 |
| pymupdf4llm | mixed | 1.0 | n/a | 1.0 | 1.0 | true | 0.6667 | 0.0 | n/a | 0.2982 |
| pymupdf4llm | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.75 | 0.1789 |
| markovo | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 |
| markovo | two-column | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 |
| markovo | table-simple | n/a | n/a | 1.0 | 1.0 | false | n/a | n/a | n/a | 0.0 |
| markovo | table-merged-header | n/a | n/a | 1.0 | 1.0 | false | n/a | n/a | n/a | 0.0 |
| markovo | rotated-header | n/a | n/a | 1.0 | 1.0 | false | n/a | n/a | n/a | 0.0 |
| markovo | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | n/a | 0.0 |
| markovo | mixed | 1.0 | n/a | 1.0 | 1.0 | false | 1.0 | 1.0 | n/a | 0.0 |
| markovo | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.5 | 0.0 |
All selected cells have error=null. Markovo's stored 0.0 seconds is a reuse placeholder, not a conversion-time measurement. Timings across these small local runs and historical API outputs are not comparable.
A whole HTML table can occupy a single physical line, allowing its strings to pass Rows or Header without preserving grouped-heading semantics. Conversely, Pipe=false does not mean the HTML table is absent. The native-text equation PDF cannot establish accuracy on fractions, integrals, or complex scientific layouts. The separate formula discussion and forced-OCR issue are contextual failure reports, not reproductions. 87
Stored OCR evidence and unresolved counters
The historical responses all report succeeded. The mixed document has job.pages_total=3 and job.pages_ocr=3, while job.billing_metrics.pagesOcr=1. Its submission estimate lists ocrPageIndexes=[2]. The estimate, completion counter, and billing counter cannot be collapsed into a verified page-by-page OCR trace. The downloaded scan text is present, but the precise routing remains unresolved. The service's quality_score is not an independent accuracy measurement. 10
| Sample | Saved response | Saved Markdown |
|---|---|---|
| prose-single | prose-single.json | prose-single.md |
| two-column | two-column.json | two-column.md |
| table-simple | table-simple.json | table-simple.md |
| table-merged-header | table-merged-header.json | table-merged-header.md |
| rotated-header | rotated-header.json | rotated-header.md |
| scan-image | scan-image.json | scan-image.md |
| mixed | mixed.json | mixed.md |
| equation | equation.json | equation.md |
The MarkItDown source probe found form-style handling for our table documents and whole-document pdfminer fallback for the column document. The table companion preserved the raw cell arrays separately from display Markdown. 5312
Sources
- pdfplumber official README
- Saved corpus scores, including all selected paths
- Installed pdfplumber Table.extract source
- MarkItDown official README
- Installed MarkItDown PDF converter source
- PyMuPDF4LLM official API reference
- Mixed-page forced-OCR text report, not reproduced here
- Formula extraction report and maintainer discussion
- Markovo API and CLI contract snapshot
- Stored mixed-document API responses
- Stored formula output, unedited
- Installed versions, actual branches and snippet checks
- Table-only companion route and serialization
- Historical native-table report, not reproduced here
- Scorer implementation copied from tools/lab_score.py


