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.

PDF 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

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

  1. pdfplumber official README
  2. Saved corpus scores, including all selected paths
  3. Installed pdfplumber Table.extract source
  4. MarkItDown official README
  5. Installed MarkItDown PDF converter source
  6. PyMuPDF4LLM official API reference
  7. Mixed-page forced-OCR text report, not reproduced here
  8. Formula extraction report and maintainer discussion
  9. Markovo API and CLI contract snapshot
  10. Stored mixed-document API responses
  11. Stored formula output, unedited
  12. Installed versions, actual branches and snippet checks
  13. Table-only companion route and serialization
  14. Historical native-table report, not reproduced here
  15. Scorer implementation copied from tools/lab_score.py