Unlimited OCR: Match the Output to Your PDF

Use text extraction for readable PDFs, configured OCR for image pages, and a layout pass for scrambled structure. Tools using the Unlimited OCR name differ in what they deliver. PDFs fall into four cases: readable prose, selectable text with complex structure, image-only scans, and a mix of those page types.

Choose an unlimited OCR route by page content

Preserve readable native text, add recognition to image pages, and send damaged structure to a layout-aware path.

Page feature How to identify it Route to use
Readable prose Copy a paragraph into an editor. Words stay in reading order Native text extraction
Selectable text with columns, grouped headers, or formulas Copy across a column boundary or table header. A sentence switches columns, labels separate from their grouped columns, or a displayed expression is missing Layout extraction, with the original beside the output
Image-only scan The page is readable on screen, but selecting its body yields no words A configured OCR engine
Mixed document Prose copies on some pages, while another page yields nothing Keep native extraction and add OCR for the image pages

Search inside the original scan needs a PDF with an added text layer. Copying a paragraph needs text. A RAG document with headings and tables needs structure that survives extraction and cleanup. A text download alone does not supply a searchable copy of the original PDF.

The browser service at unlimitedocr.com documents copied text and TXT download as its current direct outputs. Baidu's Unlimited-OCR is a separate model project with image and multi-page inference examples. Its documented path is not executed for this guide. The experiments below concern alternative PDF processing paths, not an Unlimited-OCR quality benchmark. 1 2

For Baidu's model, the official README shows a joint multi-page call, while its batch driver builds a separate request for each page. Those calls supply different context to the model. A wrapper that accepts a PDF does not, by that fact alone, use the joint input path. The source-page identity belongs in the surrounding workflow whichever call you use. 10

Use MarkItDown when the native text is enough

MarkItDown is a workable extraction baseline for readable prose and simple tables, while the tested configuration leaves image pages unread.

Its default PDF converter extracts existing text and reconstructs table-like content. The installed converter falls back to another text extractor when needed. That fallback does not recognize words in page images. We disabled plugins for the experiment. MarkItDown also documents an OCR plugin requiring additional configuration, which this run did not execute. 3 6

from pathlib import Path
        from markitdown import MarkItDown
        
        result = MarkItDown(enable_plugins=False).convert_local("input.pdf")
        Path("output.md").write_text(result.markdown, encoding="utf-8")
        

The simple invoice retained its data rows as a Markdown table. The columns sample exposed a different failure: lines from the right column interrupt a sentence from the left. 5

Output from our columns 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
        
Failure condition What appeared in our output Next action
Adjacent columns An unrelated sentence interrupts an unfinished line Use a path that reconstructs reading order
Grouped table header The group name and its child labels occupy separate rows Preserve the header relationship during cleanup
Image-only page No recognized body text Add OCR rather than rerunning text extraction

An older issue reports native PDF tables reduced to lines of text. Our simple table did produce table syntax, so that report cannot describe every PDF in the tested release. The grouped header still needed interpretation, even though its data rows survived. A pipe table is evidence of table formatting, not proof that its header relationships survived. 7 5

Use PyMuPDF4LLM for layout, with OCR configured separately

PyMuPDF4LLM preserved the columns sample's reading order, but its default call could not recover scan text without an available OCR engine.

The same columns sample retained its paragraphs in the expected sequence. The merged header sample shows the limit of that improvement: the group label remains above its child labels, without a reconstructed spanning relationship. 5

from pathlib import Path
        import pymupdf4llm
        
        text = pymupdf4llm.to_markdown("input.pdf")
        Path("output.md").write_text(text, encoding="utf-8")
        

Output from our grouped-header sample, unedited:

# **Invoice with grouped price columns** 
        
        |**Item**|**Qty**|**Price**||
        |---|---|---|---|
        |||**Unit**|**Total**|
        

Automatic OCR still needs an engine. The installed version selects an available OCR callable at runtime. Our environment selected none. The scan output was empty, and the mixed document lost its scanned page. This result describes the tested environment, not the library's ability with a configured recognition engine. 4 5 6

Failure condition What appeared in our output Next action
No available OCR engine Image-page text remains absent Configure a supported recognition engine before using this route for scans
Grouped or rotated header Data rows remain, but the complete header is not on the same row Map labels back to their source columns
Formula content A displayed expression disappears while nearby text survives Keep native text and the original formula available

The formula sample recovered only part of the reference content. A maintainer's formula discussion describes formula regions becoming pictures or being processed as ordinary text. We did not reproduce the discussion's attached textbook. 9 5

Use Markovo for an API route, then inspect structure

Markovo recovered the scan text in the stored API results, while its formula output still lost reference content.

The PDF to Markdown converter supplies a hosted conversion route when local extraction leaves image pages unread. The sample results here come from stored API jobs, not fresh requests during this writing session. These results do not establish output quality for other documents. 5

The standard-library sequence uploads the PDF, polls until the job succeeds, then downloads Markdown. Set the account credential and request bound through environment variables. Submit the upload once. After an uncertain upload response, the developer guide directs retry-safe clients to the estimate-and-confirm flow. The code below was checked by offline response replay, not by a fresh API submission. 12

import json, os, secrets, time
        from pathlib import Path
        from urllib.request import Request, urlopen
        
        base = "https://markovo.net"
        auth = {"Authorization": "Bearer " + os.environ["MARKOVO_API_KEY"]}
        boundary = secrets.token_hex()
        fields = {"capability_id": "pdf-to-markdown", "mode": "fast",
                  "max_credit_units": os.environ["MAX_CREDIT_UNITS"]}
        parts = [f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"'
                 f'\r\n\r\n{v}\r\n'.encode() for k, v in fields.items()]
        parts += [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",
                  f'--{boundary}--\r\n'.encode()]
        headers = {**auth, "Content-Type": "multipart/form-data; boundary=" + boundary}
        with urlopen(Request(base + "/v1/convert", b"".join(parts), headers)) as response:
            job_id = json.load(response)["job_id"]
        deadline = time.monotonic() + float(os.environ["JOB_TIMEOUT_SECONDS"])
        while time.monotonic() < deadline:
            with urlopen(Request(base + f"/v1/jobs/{job_id}", headers=auth)) 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["status"])
            time.sleep(float(os.environ["POLL_SECONDS"]))
        else:
            raise TimeoutError("Job did not finish")
        with urlopen(Request(base + f"/v1/jobs/{job_id}/download?format=md",
                             headers=auth)) as response:
            Path("output.md").write_bytes(response.read())
        

The Markovo CLI was not executed for this guide. Its documented interface was inspected only. 12

The mixed document retained the scan's words alongside the native prose and table. The API record reports OCR participation, although its processing and billing fields do not agree on the exact count. The formula sample recovered half of the reference expressions, so scan recovery did not justify replacing every native-text result. 5

Output from our formula sample, unedited:

$$
        \[\mathsf{E}=\mathsf{m}\mathsf{c}\mathsf{^2}\]
        $$
        
        $$
        \[a^{\wedge}2+b^{\wedge}2=c^{\wedge}2\]
        $$
        
Failure condition What appeared in our output Next action
Formula transcription Mathematical text contains unusual nested delimiters and escapes Compare the expression with native text and the source
Grouped table header HTML cells contain the labels, without establishing the spanning relationship Rebuild the grouped header explicitly
Consumer expects pipe tables The result contains HTML tables Use a consumer that accepts HTML, or convert the table representation

Keep page identity through the final handoff

Keep an external link from each extracted page to its source, then accept text, structure, and searchable-PDF output against their separate requirements.

For a document archive, the delivered PDF must retain the scan and expose its words to search. For a text index, the required passages must be present. For Markdown ingestion, column order, header relationships, and formulas must remain usable by the consumer. Our mixed-page and grouped-header results failed different parts of that handoff, despite successful conversion calls. 5

Baidu's issue tracker includes a report of missing page separators during joint multi-page inference. We did not reproduce it. Keeping source-page identities outside generated text avoids making citation mapping depend on a separator the model may omit. The batch driver and the joint call need their own page-mapping treatment. 11 10

For readable native prose, use extraction. For scrambled columns, use layout reconstruction. For image pages, use configured OCR. For formulas and grouped tables, retain the original and the native extraction while resolving the structure. Choose the final format before combining those results.

How we tested

The comparison uses synthetic PDFs with known reference text, so it supports diagnosis of these layouts rather than a general accuracy or throughput ranking.

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 extraction and scoring ran on 2026-10-24 with Python 3.11.14, MarkItDown 0.1.8, and PyMuPDF4LLM 1.28.2. The PyMuPDF4LLM call used its default layout mode. Tesseract was absent and select_ocr_function() returned None, as recorded in lab/environment.json. MarkItDown used enable_plugins=False. The exact local examples were also executed against every PDF and matched the scored outputs (lab/snippet-check.json).

The Markovo responses are historical API jobs dated 2026-10-06, supplied by the upstream runner from cache on 2026-10-24. They were scored again here against the same reference files. lab/markovo/run.json records from_cache=true. The service's rate_card_version is 2026-09-19.v3-media-unified, a record identifier, not a conversion-engine version. The scorer carries their elapsed value as 0.0. It is a reuse placeholder and must not be read as conversion speed. No fresh Markovo job was submitted for this guide. 5 13

Baidu Unlimited-OCR and the Markovo CLI were not executed for this guide. The model README and batch source were inspected at main on 2026-10-24 without a pinned commit. The CLI contract was checked against the supplied documentation for client 0.1.1. The standard-library API example passed an offline replay of upload, running/succeeded polling, and download using the stored mixed-document response. This checks the example's control flow, not current network or service behavior (lab/api-replay.json).

File Represents
prose-single.pdf Native prose
table-simple.pdf Simple ruled table
table-merged-header.pdf Header with grouped columns
two-column.pdf Parallel prose columns
scan-image.pdf Image page without native text
mixed.pdf Native prose, table, and scanned page
equation.pdf Linear displayed expressions
rotated-header.pdf Rotated header labels above ordinary data rows

Each PDF has a matching .truth.json in lab/corpus/. They are synthetic, clean inputs: this test does not cover handwriting, noisy photographs, multilingual recognition, long-document limits, or searchable-PDF generation. The formula sample consists of linear expressions. It cannot establish performance on fractions or dense mathematical layouts. The upstream folder contains other formats and tool outputs, which are outside this article's selected comparison.

Commands, run from the repository root:

tools/lab-venv/bin/python tools/lab_run.py --lab runs/2026-10-24-markovo-unlimited-ocr/lab --tools markitdown,pymupdf4llm --samples prose-single,table-simple,table-merged-header,two-column,scan-image,mixed,equation,rotated-header --heavy-cache ''
        tools/lab-venv/bin/python tools/lab_score.py --lab runs/2026-10-24-markovo-unlimited-ocr/lab --out runs/2026-10-24-markovo-unlimited-ocr/lab/scores.json --markdown runs/2026-10-24-markovo-unlimited-ocr/lab/scores.md
        tools/lab-venv/bin/python runs/2026-10-24-markovo-unlimited-ocr/lab/api-replay.py
        

tools/lab_score.py defines the metrics. prose_recall and scan_text_recall count normalized reference sentences of sufficient length found in the output. reading_order checks whether consecutive paragraph anchors appear in increasing order. table_rows_intact asks whether every cell of a reference row occurs on an output line. table_header_intact checks leaf-header labels on the same line, not grouped-column semantics. table_pipe_detected recognizes pipe-table syntax. pages_covered checks mixed-document page anchors, not the parser's physical page count. equation_recall checks normalized mathematical strings, not rendering or mathematical equivalence. equation_recall_exact uses the scorer's plain-text normalization without stripping LaTeX wrappers. It is retained as a literal-text comparison, not a measure of rendered mathematical fidelity. Null means the metric does not apply. 5

The following cells are copied from lab/scores.json for every selected tool and PDF, including lower scores. elapsed_s is a local duration for the extraction paths and a cache placeholder for Markovo. The matrix does not support a speed comparison.

tool sample prose_recall reading_order table_rows_intact table_header_intact table_pipe_detected pages_covered scan_text_recall equation_recall equation_recall_exact elapsed_s
markovo equation null null null null null null null 0.5 0.0 0.0
markovo mixed 1.0 null 1.0 1.0 False 1.0 1.0 null null 0.0
markovo prose-single 1.0 1.0 null null null null null null null 0.0
markovo rotated-header null null 1.0 1.0 False null null null null 0.0
markovo scan-image null null null null null null 1.0 null null 0.0
markovo table-merged-header null null 1.0 1.0 False null null null null 0.0
markovo table-simple null null 1.0 1.0 False null null null null 0.0
markovo two-column 1.0 1.0 null null null null null null null 0.0
markitdown equation null null null null null null null 1.0 1.0 0.0822
markitdown mixed 1.0 null 1.0 1.0 True 0.6667 0.0 null null 0.0251
markitdown prose-single 1.0 1.0 null null null null null null null 0.021
markitdown rotated-header null null 1.0 0.0 True null null null null 0.0109
markitdown scan-image null null null null null null 0.0 null null 0.0076
markitdown table-merged-header null null 1.0 0.0 True null null null null 0.012
markitdown table-simple null null 1.0 1.0 True null null null null 0.0103
markitdown two-column 0.1111 0.8 null null null null null null null 0.0369
pymupdf4llm equation null null null null null null null 0.75 0.75 0.1891
pymupdf4llm mixed 1.0 null 1.0 1.0 True 0.6667 0.0 null null 0.3139
pymupdf4llm prose-single 1.0 1.0 null null null null null null null 0.1371
pymupdf4llm rotated-header null null 1.0 0.0 True null null null null 0.1425
pymupdf4llm scan-image null null null null null null 0.0 null null 0.1775
pymupdf4llm table-merged-header null null 1.0 0.0 True null null null null 0.1475
pymupdf4llm table-simple null null 1.0 1.0 True null null null null 0.1445
pymupdf4llm two-column 1.0 1.0 null null null null null null null 0.2027

Raw Markdown lives at lab/<tool>/<sample>.md, where <tool> is markitdown, pymupdf4llm, or markovo, and <sample> is each PDF stem in the corpus table. The quoted excerpts are from lab/markitdown/two-column.md, lab/pymupdf4llm/table-merged-header.md, and lab/markovo/equation.md. The source notes are lab/source-diagnostics.md. The installed MarkItDown converter is preserved as lab/source/markitdown_pdf_converter.py. Commands and local durations are recorded in lab/runs.json and lab/local-run.txt.

For the mixed document, lab/markovo/mixed.json contains job.pages_total=3 and job.pages_ocr=3, while job.billing_metrics.pagesOcr=1 and the estimate's convert_response.jobs[0].billing_metrics.ocrPageIndexes=[2]. These fields belong to different reporting stages and are not interchangeable. They establish neither a consistent final OCR count nor an exact final routing history. The image-only snapshot has job.pages_ocr=1 with matching billing counts. We infer OCR participation, not the internal engine or a selective-routing guarantee. The stored quality_score=0.9 is a service field, not this guide's reference-based formula score. 13

The grouped-header HTML in the Markovo output occupies one line, so its header-presence check passes even though that does not prove a spanning relationship. All reported score cells use the same scorer without per-tool corrections.

Sources

  1. Baidu Unlimited-OCR official README, main
  2. UnlimitedOCR browser service: documented direct outputs
  3. MarkItDown README, v0.1.8
  4. PyMuPDF4LLM README, 1.28.2
  5. Same-corpus score matrix
  6. Installed-source inspection and runtime OCR selection
  7. MarkItDown: native PDF table report, issue 1419
  8. PyMuPDF4LLM: maintainer discussion of formula extraction
  9. Baidu Unlimited-OCR batch driver, main
  10. Baidu Unlimited-OCR: reported missing page separators
  11. Markovo API and CLI documentation snapshots
  12. Stored Markovo mixed-document API response