OCR in C#: Route PDF Text, Scans, and Tables

For OCR in C#, extract usable PDF text before sending image-only pages to an OCR path, and accept the result against the structure your application needs. The routing map has four branches: straightforward text, text with columns or grouped tables, scanned or mixed pages, and formulas that need their notation preserved.

Classify PDF pages for OCR in C#

Extract digital text, use layout extraction when columns or headers break, and apply OCR to image-only pages.

Page feature How to recognize it Processing route
Straightforward digital text Copy a paragraph into an editor. The words and sentence order match the page Extract the existing text. Use a Markdown worker when headings or tables are required
Columns or grouped tables Copied text jumps between columns, or a heading spans several leaf columns Use layout extraction, then inspect column order and header-to-cell relationships
Scanned or mixed pages Visible words on an image page are absent from copied text, even though other pages copy correctly Render that page for native image OCR, or send the PDF to an OCR-capable backend
Formulas The output changes superscripts, subscripts, or operators visible in the source Preserve the required notation and compare expressions against the source before ingestion

Classify page by page. A searchable opening page says nothing about an attached scan. Copying text is a quick diagnostic, not proof that every glyph or table relationship survived extraction. Our mixed document retained its digital prose and table in both local paths while the scanned page contributed no text. 12

Native C# needs an image boundary

Tesseract accepts image inputs. Its ability to produce searchable PDF output does not make PDF an accepted input format. A native C# pipeline therefore needs a PDF renderer before the image-recognition call. 1 Rotation can also affect recognition line segmentation, so orientation belongs in the image path as well as the table inspection. 10

The charlesw wrapper's Pix.LoadFromFile calls Leptonica's image reader. It does not iterate PDF pages or render them. Its GetText method returns recognized text, while separate interfaces expose richer recognition output. A string alone carries no promise about table-cell relationships. 2 3

The native wrapper was not executed for this guide. Its setup needs compatible native dependencies and language data. Keep the PDF page identity alongside each rendered image. 4 19

For existing digital text, PdfPig documents content-order extraction and word-based layout methods, and cautions that its raw page text follows internal content order. It was not executed for this guide. Windows.Media.Ocr is another image interface, with package-identity requirements for desktop use. It was also not executed for this guide. 5 6

Keep MarkItDown for straightforward text

MarkItDown suits straightforward digital text in this test, but its column interleaving makes it unsuitable for our column sample without another layout step. 7 12

A C# application can run a separate Python worker and consume its Markdown. The worker boundary leaves the native application in charge of source identity and error handling.

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")
        

On the sample PDF used for this guide, the straightforward prose survived intact. The simple invoice retained its row text and header together. The column document exposed a different failure: lines from the invoice discussion alternate with lines from the demand-forecast discussion. 12

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
        

The words exist, but a downstream chunk can join unrelated passages. Retrying character recognition would address the wrong layer. Compare a continuous paragraph down the left column with the corresponding output passage to identify the ordering failure. 14

The installed PDF converter uses text-extraction branches and a pdfminer fallback. We ran it with plugins disabled, so the blank scan describes this configuration. It does not establish that every MarkItDown plugin setup lacks OCR. 8 13

Failure condition Output symptom Route adjustment
Parallel columns Unrelated sentence fragments alternate Change layout extraction before changing recognition
Scanned page Visible words contribute no extracted text Add an image-recognition path
Grouped header Leaf headings occupy separate output lines Rebuild and inspect the header relationship

Use PyMuPDF4LLM when column order matters

PyMuPDF4LLM preserved the column passages in our test, while the grouped header and unrecognized scan still needed separate handling. 9 12

The local worker can use the same process boundary as MarkItDown. With the installed layout dependency, we used the default conversion call rather than custom column coordinates.

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

On the sample PDF used for this guide, paragraphs stayed in their intended column sequence. The invoice with a grouped price heading shows the next boundary: the item and quantity headings occupy a different header line from unit and total. 12 15

Output from our grouped-header sample, unedited:

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

The consumer still needs a rule linking the price group to its leaf columns. Render the table to trace that relationship. 15

The image-only page remained blank in this environment. Although the public call permits OCR by default, the installed engine selector found no available OCR function. The source then disables OCR for that run. A C# job runner should distinguish a worker returning successfully from the worker recognizing its image pages. 13

Failure condition Output symptom Route adjustment
Missing OCR dependencies Digital pages have text, the scan does not Configure an OCR engine or use an OCR-capable backend
Grouped or rotated headings Row values remain readable while headings split or lose alignment Inspect the rendered header and its cell assignments
Formula notation A source expression changes in extraction Use expression-specific acceptance before indexing

Use Markovo for scanned and mixed PDFs

The saved Markovo API run recovered the scan text that the local configurations missed, but it also changed some simple formula notation. 12 16

Its PDF-to-Markdown output gives a C# application a downloadable document rather than an in-process image-recognition result. The wire flow is upload, poll until success, then download Markdown. In .NET, the same requests belong in your HTTP client. There is no Markovo C# SDK assumed here. 11

The standard-library Python example follows that HTTP contract. Set the API credential and your chosen request ceiling in server-side environment variables before running it. The ceiling is a required request field, not a quality setting. This example was checked by offline replay of the saved responses. It was not sent to the live API during this guide. 11 18

import json
        import os
        import secrets
        import time
        import urllib.request
        from pathlib import Path
        
        
        def convert(source="input.pdf", target="output.md"):
            base = "https://markovo.net"
            headers = {"Authorization": "Bearer " + os.environ["MARKOVO_API_KEY"]}
            boundary = secrets.token_hex(16)
            fields = {"capability_id": "pdf-to-markdown", "mode": "fast",
                      "max_credit_units": str(int(os.environ["MARKOVO_MAX_CREDIT_UNITS"]))}
            parts = [f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
                     for k, v in fields.items()]
            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(source).read_bytes() + f'\r\n--{boundary}--\r\n'.encode())
            request = urllib.request.Request(base + "/v1/convert", data=b"".join(parts),
                headers={**headers, "Content-Type": "multipart/form-data; boundary=" + boundary})
            with urllib.request.urlopen(request, timeout=60) as response:
                job_id = json.load(response)["job_id"]
            deadline = time.monotonic() + 600
            while time.monotonic() < deadline:
                request = urllib.request.Request(base + "/v1/jobs/" + job_id, headers=headers)
                with urllib.request.urlopen(request, timeout=60) as response:
                    job = json.load(response)["job"]
                if job["status"] == "succeeded":
                    break
                if job["status"] == "failed":
                    raise RuntimeError(job["status"])
                time.sleep(3)
            else:
                raise TimeoutError("Job did not succeed before the polling deadline")
            request = urllib.request.Request(base + "/v1/jobs/" + job_id + "/download?format=md", headers=headers)
            with urllib.request.urlopen(request, timeout=60) as response:
                Path(target).write_bytes(response.read())
        
        
        if __name__ == "__main__":
            convert()
        

The documented Markovo CLI was not executed for this guide. It writes conversion results to a destination directory. 11

In the saved run, the mixed PDF's scan passage appeared alongside its digital prose and table. The tables used embedded HTML rather than pipe-table syntax. If your Markdown consumer strips HTML, that output needs a deliberate table-handling step. 12

The formula source contains a linear sum expression, but the output introduces a subscript-text command and additional notation that do not match the source expression. 16

Output from our formula sample, unedited:

$$
        \[\mathsf{s u m}_{\_}\{i=1\}^{\wedge}\{n\}x\textsubscript{_}i\]
        $$
        

MarkItDown retained the source's simple expression strings in this sample, while the Markovo result altered some of them. Scan recovery therefore does not settle mathematical fidelity. Keep source expressions beside converted notation when formulas determine the meaning of a chunk. 12 16

Failure condition Output symptom Route adjustment
Consumer rejects embedded HTML A converted table disappears or becomes literal tags Preserve supported HTML or transform the table before ingestion
Formula text is rewritten Operators or subscript notation differ from the source Compare the expressions and retain a reviewed representation
Submission response is lost The client has no confirmed job identifier Resolve the submission before sending another conversion request

The compatibility upload endpoint is not the retry-safe submission path. For automation that must recover from an ambiguous submission, the documented estimate-and-confirm flow provides replay semantics. Polling and downloading an existing job are separate from submitting another upload. 11

Route failures by the missing structure

A C# ingestion job should retry the failed processing layer and accept content by page coverage, reading continuity, and the structures it will consume.

What the application receives What to inspect Action supported by this test
Text on digital pages, silence on a visible scan Compare the scan passage with its corresponding output Add OCR for the missing image page
All the words, unrelated sentence fragments Follow a paragraph down its source column Change the layout path
Table values with uncertain labels Trace grouped headings to leaf headings and row values Reconstruct the table relationship before using the values
Recognized prose with changed formulas Compare operators and script placement with the source Keep a reviewed formula representation

Keep the source page identity through native image recognition, local worker execution, and HTTP jobs. That identity lets the application send an omitted scan back through OCR without treating an already readable paragraph as an image. Accept tables by their labels and formulas by their notation, so a successful conversion job cannot hide a structurally unusable result.

How we tested

These results compare the reported configurations on a small synthetic PDF corpus. They do not rank native C# OCR engines.

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.

Corpus and dates

File Represented input
lab/corpus/prose-single.pdf Digital prose in a single column
lab/corpus/table-simple.pdf Invoice with a simple header
lab/corpus/table-merged-header.pdf Invoice with grouped price headings
lab/corpus/two-column.pdf Independent prose passages in parallel columns
lab/corpus/scan-image.pdf Image-only text page
lab/corpus/mixed.pdf Digital prose, table, and scanned page
lab/corpus/equation.pdf Simple linear expressions stored as text
lab/corpus/rotated-header.pdf Invoice with rotated headings

Each PDF has a same-stem .truth.json file. We selected only these PDF inputs from the supplied corpus. No renderer experiment was present. Local execution and scoring took place on November 2, 2026, with Python 3.11.14. MarkItDown was 0.1.8, with pdfminer.six 20260107 and pdfplumber 0.11.10. PyMuPDF4LLM, PyMuPDF, and pymupdf-layout were all 1.28.2. 17

Markovo responses were supplied by the upstream runner. Their original job.created_at timestamps are October 6, 2026. We reused and rescored those outputs rather than uploading again. File names and byte sizes match the current PDFs. The snapshots contain no source content hash, so historical byte identity was not independently established. The API engine version is not reported. The recorded rate_card_version identifies billing configuration, not an OCR engine build. 17

The native Tesseract wrapper's constructor and image loader were statically checked against tag 5.2.0. That tag is not a measured native-core version. PdfPig was checked against its unpinned README accessed November 2, 2026. Neither native path, Windows.Media.Ocr, nor the Markovo CLI was executed. 2 4 5 6 19

Commands and options

From the repository root, with the preinstalled lab environment:

PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python tools/lab_run.py --lab runs/2026-11-02-markovo-ocr-c/lab --tools markitdown,pymupdf4llm --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 tools/lab_score.py --lab runs/2026-11-02-markovo-ocr-c/lab --out runs/2026-11-02-markovo-ocr-c/lab/scores.json
        PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-11-02-markovo-ocr-c/lab/summarize.py
        PYTHONDONTWRITEBYTECODE=1 tools/lab-venv/bin/python runs/2026-11-02-markovo-ocr-c/lab/validate_examples.py
        

MarkItDown used enable_plugins=False. PyMuPDF4LLM used to_markdown defaults with its layout package installed. Its select_ocr_function() returned None. The installed parse_document implementation consequently selected OCRMode.NEVER. We did not explicitly disable OCR in the call. The Python worker examples were executed after replacing only the placeholder input/output paths, and their outputs matched the matrix files. The HTTP example was exercised with urlopen mocked against all supplied PDF job snapshots and downloads. This validates the illustrated response flow, not live service availability. 13 18

Scoring and its limits

The table below is a projection of lab/scores.json. Values are copied without rescaling. A value of n/a means the scorer did not apply that metric to the fixture. The score definitions come from tools/lab_score.py. 12

Column Source metric and meaning
Prose prose_recall: normalized truth sentences of at least four words found in output
Order reading_order: increasing positions of adjacent paragraph anchors, using each paragraph's opening six words. Missing anchors fail
Rows table_rows_intact: truth rows whose cell strings all appear on some single output line
Header table_header_intact: leaf-header strings, or the ordinary header, together on one output line
Pipes table_pipe_detected: pipe-table syntax and a separator line were detected
Pages pages_covered: mixed-document page anchors found in output
Scan scan_text_recall: scan truth sentences found after the prose normalization
Math equation_recall: source expression strings found after removing specified math delimiters, font wrappers, escapes, and whitespace
Seconds elapsed_s: recorded execution duration. Cached zero values are not service latency measurements
Tool Sample Prose Order Rows Header Pipes Pages Scan Math Seconds
markitdown prose-single 1.0 1.0 n/a n/a n/a n/a n/a n/a 0.0205
markitdown table-simple n/a n/a 1.0 1.0 true n/a n/a n/a 0.0109
markitdown table-merged-header n/a n/a 1.0 0.0 true n/a n/a n/a 0.0117
markitdown two-column 0.1111 0.8 n/a n/a n/a n/a n/a n/a 0.0347
markitdown scan-image n/a n/a n/a n/a n/a n/a 0.0 n/a 0.0079
markitdown mixed 1.0 n/a 1.0 1.0 true 0.6667 0.0 n/a 0.023
markitdown equation n/a n/a n/a n/a n/a n/a n/a 1.0 0.0516
markitdown rotated-header n/a n/a 1.0 0.0 true n/a n/a n/a 0.0108
pymupdf4llm prose-single 1.0 1.0 n/a n/a n/a n/a n/a n/a 0.1319
pymupdf4llm table-simple n/a n/a 1.0 1.0 true n/a n/a n/a 0.1497
pymupdf4llm table-merged-header n/a n/a 1.0 0.0 true n/a n/a n/a 0.154
pymupdf4llm two-column 1.0 1.0 n/a n/a n/a n/a n/a n/a 0.1464
pymupdf4llm scan-image n/a n/a n/a n/a n/a n/a 0.0 n/a 0.1832
pymupdf4llm mixed 1.0 n/a 1.0 1.0 true 0.6667 0.0 n/a 0.3144
pymupdf4llm equation n/a n/a n/a n/a n/a n/a n/a 0.75 0.2312
pymupdf4llm rotated-header n/a n/a 1.0 0.0 true n/a n/a n/a 0.1545
markovo prose-single 1.0 1.0 n/a n/a n/a n/a n/a n/a unknown (stored 0.0)
markovo table-simple n/a n/a 1.0 1.0 false n/a n/a n/a unknown (stored 0.0)
markovo table-merged-header n/a n/a 1.0 1.0 false n/a n/a n/a unknown (stored 0.0)
markovo two-column 1.0 1.0 n/a n/a n/a n/a n/a n/a unknown (stored 0.0)
markovo scan-image n/a n/a n/a n/a n/a n/a 1.0 n/a unknown (stored 0.0)
markovo mixed 1.0 n/a 1.0 1.0 false 1.0 1.0 n/a unknown (stored 0.0)
markovo equation n/a n/a n/a n/a n/a n/a n/a 0.5 unknown (stored 0.0)
markovo rotated-header n/a n/a 1.0 1.0 false n/a n/a n/a unknown (stored 0.0)

All selected cells have a null error field. The scorer does not count unique table cells or validate grouped-header semantics. In particular, compact HTML can put an entire table on a physical line and satisfy the row/header checks without proving cell ownership. A false Pipes value describes output syntax, not absent table content. Paragraph-anchor order can also look high while full sentences are fragmented: compare MarkItDown's column Prose and Order values. 12 13

The Math column measures normalized string recoverability for linear expressions. It does not establish mathematical equivalence or performance on two-dimensional equations. Markovo's lower result is retained in the table. The cached API durations are unknown, so no service-speed comparison follows from this test. 12

API routing fields and raw evidence

The raw scan snapshot reports pages_total=1, pages_ocr=1, and conversion billing_metrics.ocrPageIndexes=[0]. The mixed snapshot reports pages_total=3 and pages_ocr=3, while its job billing metrics report pagesOcr=1 and its conversion response lists ocrPageIndexes=[2]. Digital-only samples also have pages_ocr=1 alongside billing pagesOcr=0 and empty conversion indexes. These fields do not establish identical OCR coverage. The discrepancy is unresolved. The restored scan passage establishes output recovery, while the conversion indexes indicate the scan-page route in the stored response. Service quality_score is not used as a substitute for the shared scorer. 17

Raw Markdown files use lab/{tool}/{sample}.md, where tool is markitdown, pymupdf4llm, or markovo, and sample is any file stem in the corpus table. The excerpts above come from lab/markitdown/two-column.md, lab/pymupdf4llm/table-merged-header.md, and lab/markovo/equation.md. API snapshots use lab/markovo/{sample}.json. 13 14 15 16 17 18

References

Sources

  1. Tesseract input formats
  2. Tesseract wrapper 5.2.0: Pix.cs
  3. Tesseract wrapper 5.2.0: Page.cs
  4. Tesseract wrapper 5.2.0: README
  5. PdfPig README, unpinned snapshot
  6. Microsoft Windows.Media.Ocr API
  7. MarkItDown v0.1.8 README
  8. MarkItDown v0.1.8 PDF converter
  9. PyMuPDF4LLM API documentation
  10. Tesseract image quality and segmentation
  11. Markovo developer and CLI contract, local snapshots checked
  12. Shared scorer results, complete stored matrix
  13. Installed-source inspection and scoring limitations
  14. Unedited MarkItDown column output
  15. Unedited PyMuPDF4LLM grouped-header output
  16. Unedited stored Markovo formula output
  17. Corpus hashes, versions, original API dates and routing fields
  18. Local worker execution and offline HTTP fixture replay
  19. Tesseract wrapper 5.2.0: engine constructor and Process