PDF24 OCR: From searchable PDF to Markdown
Use PDF24 OCR to create searchable text, then extract that text into Markdown and repair the structures your document needs. Choose among five cases: readable text, image-only scans, stale hidden text, mixed documents, and pages with tables or columns. 110
Classify text layers before choosing a path
Match the conversion path to the difference between the visible page and the text you can paste from it.
| Feature | How to identify it | Path |
|---|---|---|
| Readable text | Paste a body paragraph into an editor: its words and sentence order match the page | Extract existing text |
| Image-only scan | A visible body sentence cannot be selected or found by text search | Apply OCR, then extract text or export Markdown |
| Stale hidden text | Search or paste returns words that differ from the visible scan | Replace the incorrect text layer before relying on extraction |
| Mixed document | A native page pastes correctly while a scanned attachment contributes no body text | Handle the missing pages, keeping the native content |
| Structured page | Words survive, but columns interleave or values lose their headings | Use layout extraction and preserve the heading relationships |
A nonempty text layer can still contain the wrong words. We added deliberately incorrect invisible text to a scan without changing its rendered image. Both local extraction paths returned that hidden text instead of the visible paragraph. This control models the symptom of a stale OCR layer. It does not reproduce PDF24's recognition process. 11
In our mixed sample, the extraction-only paths recovered native prose and table content but left the scanned body's words out. A text-bearing cover page therefore cannot stand in for the attached scan when deciding whether the exported document is complete. 10
Use PDF24 OCR for searchable text
PDF24 OCR produces searchable PDF content, while text extraction and Markdown structure remain separate tasks.
The official online workflow is to select the document, set its recognition options, run OCR, and download the searchable PDF. For a plain text handoff, PDF24 Support describes OCR followed by its PDF to Text tool. Its support answer also explains why a Word export can retain a scanned page as an image with hidden text. A successful search inside the PDF does not establish editable paragraphs in another format. PDF24 Online and Creator were not executed for this guide. 14
- Run PDF24 OCR on the scan and save the resulting PDF
- Paste a visible body sentence into an editor and compare its wording with the page
- Send a matching text layer to an extractor, or use an OCR-capable export path when the body text is still missing
Creator's skip options distinguish the whole file from individual pages. -skipFilesWithText skips text-bearing files. -skipPagesWithText skips text-bearing pages. For a mixed document, the relevant distinction is whether the scan itself has readable text. 2
PDF24 Support says the online Force OCR option adds recognized text while retaining the old text. Its documented Windows workaround uses Remove existing text with Skip pages with text disabled. The support team limits that removal advice to scanned documents whose existing text is the scanner's hidden layer, because removal affects all text. Applying it indiscriminately to native pages discards their text too. 3
Use MarkItDown for text that already exists
MarkItDown exports existing PDF text, including an incorrect hidden layer that happens to be extractable.
This Python example uses its core conversion path with plugins disabled. It writes the returned text to Markdown without adding a recognition service. Use it when a copied paragraph already matches the source and ordinary text is the required output. 56
from pathlib import Path
from markitdown import MarkItDown
result = MarkItDown(enable_plugins=False).convert("input.pdf")
Path("output.md").write_text(result.markdown, encoding="utf-8")
Output from our stale text layer sample, unedited:
WRONG LAYER ALPHA.
WRONG LAYER BETA.
WRONG LAYER GAMMA.
The visible scan contains a paragraph about image-only extraction. The exported words above come entirely from the layer we inserted. Swapping the text extractor does not repair the content of that layer: PyMuPDF4LLM returned the same incorrect lines with OCR disabled. 1011
On our column sample, MarkItDown alternated invoice-reconciliation fragments with demand-forecasting fragments. The installed converter makes a document-wide choice: when it detects form-like content, it joins rich extraction with page text. Otherwise it uses its text fallback for the document. Appending our table page changed line joining but did not change the observed sentence recovery. The source explains a possible cross-page dependency, while this control supplies no evidence of an additional reading-order loss. 6810
| Failure condition | Observable result | Response |
|---|---|---|
| Incorrect existing text | Export repeats the hidden words instead of the visible sentence | Repair or replace the text layer |
| Scan without text | Scan body contributes no words | Add an OCR-capable path |
| Parallel columns | Sentence fragments alternate between unrelated topics | Change the layout extraction path |
| Grouped or rotated headers | Data rows survive, but their header arrangement changes | Retain explicit labels for the affected columns |
Use PyMuPDF4LLM for the tested column layout
PyMuPDF4LLM preserved our column sample's sentences, but disabling OCR left its scan text missing.
This example deliberately sets use_ocr=False to isolate layout extraction. The setting describes the tested configuration, not the library's full OCR support. Its installed implementation forwards that setting to document parsing. 7
from pathlib import Path
import pymupdf4llm
text = pymupdf4llm.to_markdown("input.pdf", use_ocr=False)
Path("output.md").write_text(text, encoding="utf-8")
The column output keeps invoice reconciliation together before moving to returns processing. The same layout path also retained the simple table's rows. 10
Output from our grouped-header table sample, unedited:
|**Item**|**Qty**|**Price**||
|---|---|---|---|
|||**Unit**|**Total**|
The broad Price label and the Unit/Total labels occupy separate rows. A downstream consumer still needs to know which values belong under unit price and total price. For a consumer that expects a flat table, explicit labels such as "Unit price" and "Total price" carry that relationship without requiring it to infer the grouping from the empty cells. 10
| Failure condition | Observable result | Response |
|---|---|---|
| OCR disabled on scans | Visible scan words are absent | Enable an OCR configuration or choose a path that reads the scan |
| Incorrect hidden text | The deliberately wrong lines survive | Correct the layer before treating it as content |
| Grouped headings | A broad label and its child labels occupy separate output rows | Flatten the relationship into explicit column names |
| Printed formula notation | Some expression strings change | Preserve the source notation alongside the exported expression |
Use Markovo when the scan must enter Markdown
The archived Markovo path recovered the scanned paragraph while still damaging some printed formula notation.
Its PDF to Markdown conversion path provides a direct export route when the document needs scan recognition as well as text extraction. In the shared corpus, its output included the scanned body that the extraction-only controls omitted. The archived job reports OCR activity. These are reused API results, not fresh service measurements. 1013
The documented REST handoff submits the PDF with POST /v1/convert, polls GET /v1/jobs/{job_id} until succeeded, and retrieves GET /v1/jobs/{job_id}/download. The standard-library example below uses an account API key and caller-supplied environment settings. It does not retry an ambiguous conversion submission. We checked its request construction and polling against archived responses without sending another job. 1215
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"]}
def call(path, data=None, content_type="application/json"):
request = Request(base + path, data=data,
headers={**headers, "Content-Type": content_type})
with urlopen(request, timeout=60) as response:
return response.read()
boundary = uuid.uuid4().hex
body = b""
for name, value in {"capability_id": "pdf-to-markdown",
"max_credit_units": os.environ["MARKOVO_MAX_CREDIT_UNITS"]}.items():
body += (f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"'
f'\r\n\r\n{value}\r\n').encode()
body += (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; '
'filename="input.pdf"\r\nContent-Type: application/pdf\r\n\r\n').encode()
body += Path("input.pdf").read_bytes() + f"\r\n--{boundary}--\r\n".encode()
created = json.loads(call("/v1/convert", body, "multipart/form-data; boundary=" + boundary))
job_id = created["job_id"]
deadline = time.monotonic() + float(os.environ["MARKOVO_TIMEOUT_SECONDS"])
while True:
job = json.loads(call(f"/v1/jobs/{job_id}"))["job"]
if job["status"] == "succeeded":
break
if job["status"] in {"failed", "cancelled"}:
raise RuntimeError(job["status"])
if time.monotonic() >= deadline:
raise TimeoutError("Polling expired; inspect the existing job")
time.sleep(2)
Path("output.md").write_bytes(call(f"/v1/jobs/{job_id}/download?format=md"))
Markovo retained only half of the printed expression strings in our formula sample. The sum notation contains separated letters and damaged subscript markup. 1014
Output from our printed-formula sample, unedited:
$$
\[\mathsf{s u m}_{\_}\{i=1\}^{\wedge}\{n\}x\textsubscript{_}i\]
$$
| Failure condition | Observable result | Response |
|---|---|---|
| Formula-heavy material | Sum notation contains separated letters and escaped markup | Retain the source expression during correction |
| Consumer accepts only pipe tables | Table output uses HTML markup | Convert the representation while preserving labels |
| Grouped headings | Labels appearing in the output do not establish their semantic grouping | Keep parent-child heading relationships explicit |
| Incorrect hidden layer control | No archived API result exists for this added case | No claim about this service's handling of that control |
For the PDF24-to-Markdown handoff, extract a correct existing text layer, replace a stale layer before using its words, and add OCR for missing scan text. When the words are present but the relationships are damaged, repair the layout or column labels.
How we tested
The measurements separate fresh local extraction controls from reused API evidence.
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 execution and scoring took place on 2026-11-05, using Python 3.11.14. The common PDF corpus consists of the following files, each paired with a same-stem .truth.json file. All are synthetic fixtures, not a representative sample of production documents.
| File | Shape represented |
|---|---|
prose-single.pdf |
Native single-column prose |
table-simple.pdf |
Simple table with explicit headers |
table-merged-header.pdf |
Table with grouped price headings |
two-column.pdf |
Independent prose columns |
scan-image.pdf |
Raster-only page without a text layer |
mixed.pdf |
Native prose, a table, and a scanned page |
equation.pdf |
Printed linear expression strings, not scanned mathematical layout |
rotated-header.pdf |
Vertical headers above ordinary data rows |
We added scan-stale-layer.pdf, with deliberately wrong invisible text over the unchanged scan, and columns-plus-table.pdf, with the identical column page followed by the simple-table page. The former's rendered pixels matched the original scan. The latter tests a source-level dependency on form-like pages. MarkItDown's sentence and order scores did not worsen after the table was appended. Neither added control was submitted to Markovo. 11
| Path | Version and configuration | Execution boundary |
|---|---|---|
| MarkItDown | 0.1.8, enable_plugins=False |
Fresh local extraction |
| PyMuPDF4LLM | 1.28.2, use_ocr=False |
Fresh local layout extraction with OCR disabled |
| Markovo | Archived API result, recorded rate-card identifier 2026-09-19.v3-media-unified is not a verified software version |
Core-corpus jobs created on 2026-10-06, rescored locally, no fresh API calls |
| PDF24 Online / Creator | Online page and Creator 11 manual inspected on 2026-11-05 | not executed for this guide |
The exact Python examples also ran against lab/sample.pdf, with output hashes, first output lines, and elapsed times recorded in lab/example-receipt.json. The API example was replayed against the core-corpus snapshots using a substituted HTTP transport. This checks multipart fields, polling, and byte-identical downloads, not live authentication or service behavior. The CLI was not executed for this guide. Environment settings in the API example are MARKOVO_API_KEY, MARKOVO_MAX_CREDIT_UNITS, and MARKOVO_TIMEOUT_SECONDS. 1215
Commands from the repository root, with bytecode writing disabled:
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-11-05-markovo-pdf24-ocr/lab/run_pdf_experiment.py
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-11-05-markovo-pdf24-ocr/lab/run_pdf_experiment.py --tool markitdown
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-11-05-markovo-pdf24-ocr/lab/run_pdf_experiment.py --tool pymupdf4llm
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python tools/lab_score.py --lab runs/2026-11-05-markovo-pdf24-ocr/lab --out runs/2026-11-05-markovo-pdf24-ocr/lab/scores.json --markdown runs/2026-11-05-markovo-pdf24-ocr/lab/scores.md
PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-11-05-markovo-pdf24-ocr/lab/check_examples.py
PDF results are selected directly from lab/scores.json. Raw outputs use lab/<tool>/<sample>.md. API snapshots use lab/markovo/<sample>.json. Raw installed source and its hashes are retained under lab/sources/. The scorer is tools/lab_score.py, with a matching copy included as evidence. 671016
| tool | sample | prose_recall | reading_order | table_rows_intact | table_header_intact | table_pipe_detected | pages_covered | scan_text_recall | equation_recall | elapsed_s |
|---|---|---|---|---|---|---|---|---|---|---|
| markitdown | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0188 |
| markitdown | table-simple | n/a | n/a | 1.0 | 1.0 | yes | n/a | n/a | n/a | 0.0065 |
| markitdown | table-merged-header | n/a | n/a | 1.0 | 0.0 | yes | n/a | n/a | n/a | 0.0062 |
| markitdown | two-column | 0.1111 | 0.8 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0463 |
| markitdown | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.0029 |
| markitdown | mixed | 1.0 | n/a | 1.0 | 1.0 | yes | 0.6667 | 0.0 | n/a | 0.0158 |
| markitdown | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | 0.0073 |
| markitdown | rotated-header | n/a | n/a | 1.0 | 0.0 | yes | n/a | n/a | n/a | 0.006 |
| markitdown | scan-stale-layer | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.0036 |
| markitdown | columns-plus-table | 0.1111 | 0.8 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0263 |
| pymupdf4llm | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.0779 |
| pymupdf4llm | table-simple | n/a | n/a | 1.0 | 1.0 | yes | n/a | n/a | n/a | 0.0729 |
| pymupdf4llm | table-merged-header | n/a | n/a | 1.0 | 0.0 | yes | n/a | n/a | n/a | 0.0522 |
| pymupdf4llm | two-column | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.052 |
| pymupdf4llm | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.0949 |
| pymupdf4llm | mixed | 1.0 | n/a | 1.0 | 1.0 | yes | 0.6667 | 0.0 | n/a | 0.1917 |
| pymupdf4llm | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.75 | 0.0334 |
| pymupdf4llm | rotated-header | n/a | n/a | 1.0 | 0.0 | yes | n/a | n/a | n/a | 0.0572 |
| pymupdf4llm | scan-stale-layer | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | 0.1048 |
| pymupdf4llm | columns-plus-table | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | 0.1097 |
| markovo | prose-single | 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 | no | n/a | n/a | n/a | 0.0 |
| markovo | table-merged-header | n/a | n/a | 1.0 | 1.0 | no | 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 | 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 | no | 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 |
| markovo | rotated-header | n/a | n/a | 1.0 | 1.0 | no | n/a | n/a | n/a | 0.0 |
prose_recallandscan_text_recallcount normalized reference sentences present in the output, keeping sentences with at least four wordsreading_orderchecks adjacent paragraph anchors made from their first six words. It does not inspect every sentence's positiontable_rows_intactrequires all cells in each reference row to appear on an output linetable_header_intactchecks whether all leaf-header strings appear on one output line. It does not test merged-header semanticstable_pipe_detectedchecks for pipe-table syntax, not table correctnesspages_coveredchecks the mixed file's page anchors. It does not count PDF page objectsequation_recallnormalizes mathematical delimiters, selected wrappers, escapes, and whitespace before matching expression stringselapsed_srecords one local call with imports and converter initialization outside the timer, anderrorpreserves exceptions
These are fixture checks, not general OCR accuracy estimates. Markovo's HTML table serialized onto one line can satisfy the header-string check without preserving a grouped header's meaning. Its elapsed_s=0.0 values are cache placeholders, not measured conversion times. Every Markovo core-corpus row is shown, including formula and pipe-table results that scored lower. Its quality_score field is not the scorer's ground-truth measure.
The mixed-file snapshot reports job.pages_ocr=3, while convert_response.jobs[0].billing_metrics.ocrPageIndexes=[2] is the estimate-stage list. That discrepancy prevents an inference that only the scanned page was processed with OCR. The final output contains its scan text, but these fields do not establish a consistent page-by-page route. 13
The issue reports are input- and version-specific. We inspected MarkItDown's document-wide branch alongside issue #2580, but our appended-table control did not reproduce an additional score loss. The rotated-header report #2560 uses a different PDF. This guide does not claim to reproduce or resolve it. All measured conclusions refer to the supplied fixtures and pinned local configurations. 89
Sources
- PDF24 Online OCR: searchable PDF output
- PDF24 Creator 11 manual, OCR and skip options
- PDF24 Support: Force OCR and existing text
- PDF24 Support: searchable scan and editable output
- MarkItDown v0.1.8 README
- Installed MarkItDown PDF converter source
- Installed PyMuPDF4LLM 1.28.2 API source
- MarkItDown issue 2580: document-wide extraction branch
- MarkItDown issue 2560: rotated PDF headers
- PDF fixture scores, local execution and archived API outputs
- Hidden-layer image identity and API archive observations
- Provided Markovo developer reference snapshot
- Archived mixed-document API response
- Archived formula output, unedited
- Executed local examples and offline API replay receipt
- Scorer implementation and metric definitions


