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
- Baidu Unlimited-OCR official README, main
- UnlimitedOCR browser service: documented direct outputs
- MarkItDown README, v0.1.8
- PyMuPDF4LLM README, 1.28.2
- Same-corpus score matrix
- Installed-source inspection and runtime OCR selection
- MarkItDown: native PDF table report, issue 1419
- PyMuPDF4LLM: maintainer discussion of formula extraction
- Baidu Unlimited-OCR batch driver, main
- Baidu Unlimited-OCR: reported missing page separators
- Markovo API and CLI documentation snapshots
- Stored Markovo mixed-document API response


