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

  1. Run PDF24 OCR on the scan and save the resulting PDF
  2. Paste a visible body sentence into an editor and compare its wording with the page
  3. 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_recall and scan_text_recall count normalized reference sentences present in the output, keeping sentences with at least four words
  • reading_order checks adjacent paragraph anchors made from their first six words. It does not inspect every sentence's position
  • table_rows_intact requires all cells in each reference row to appear on an output line
  • table_header_intact checks whether all leaf-header strings appear on one output line. It does not test merged-header semantics
  • table_pipe_detected checks for pipe-table syntax, not table correctness
  • pages_covered checks the mixed file's page anchors. It does not count PDF page objects
  • equation_recall normalizes mathematical delimiters, selected wrappers, escapes, and whitespace before matching expression strings
  • elapsed_s records one local call with imports and converter initialization outside the timer, and error preserves 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

  1. PDF24 Online OCR: searchable PDF output
  2. PDF24 Creator 11 manual, OCR and skip options
  3. PDF24 Support: Force OCR and existing text
  4. PDF24 Support: searchable scan and editable output
  5. MarkItDown v0.1.8 README
  6. Installed MarkItDown PDF converter source
  7. Installed PyMuPDF4LLM 1.28.2 API source
  8. MarkItDown issue 2580: document-wide extraction branch
  9. MarkItDown issue 2560: rotated PDF headers
  10. PDF fixture scores, local execution and archived API outputs
  11. Hidden-layer image identity and API archive observations
  12. Provided Markovo developer reference snapshot
  13. Archived mixed-document API response
  14. Archived formula output, unedited
  15. Executed local examples and offline API replay receipt
  16. Scorer implementation and metric definitions