Marker PDF to Markdown: Table and Scan Repairs
Use Marker to convert a PDF to Markdown with layout and OCR available, then repair the specific failure: missing text needs recognition, interleaved paragraphs need reading-order recovery, and flattened headers need table reconstruction. Classify the input into ordinary text, multiple columns, tables, scanned or mixed pages, and equations before choosing a path.
Classify the PDF before converting it
Match the failure to the page feature before changing conversion settings.
| Page feature | How to identify the failure | Conversion or repair path |
|---|---|---|
| Ordinary text | Copy a paragraph into an editor and compare the sentence endings | Use a text baseline if the sentences remain intact |
| Multiple columns | Follow a paragraph to its end, then locate the next paragraph from the same column | Use layout recovery when output alternates between columns |
| Tables | Trace a value upward to its leaf header and any parent heading | Repair headers or retain recognized HTML when the relationship disappears |
| Scanned or mixed pages | Select a sentence on the suspect page and search for that sentence in the output | Use OCR for absent image text, including inside otherwise digital documents |
| Equations | Compare the expression, including subscripts and grouping, with the source | Use a math-aware path when plain characters cannot represent the expression |
These checks separate content that disappeared from content that survived in the wrong relationship. A table containing all its values can still associate a price with the wrong header. A readable opening page can coexist with a blank scanned appendix. 7
Marker, packaged as marker-pdf, can request OCR in its fast path; disabling OCR also closes the fallback for unresolved digital tables. Marker was not executed for this guide. Its supplied upstream outputs contain recovered scan text, coherent column paragraphs, and a flattened grouped-price header, but the manifest supplies no command or mode. They cannot establish which settings produced those results. Here, “Price Unit” occupies a leaf-header position rather than preserving the parent heading over the price columns. 1278
Output from our grouped-price table sample, unedited:
## **Invoice with grouped price columns**
| Item | Qty | Price Unit | Total |
|------------------|-----|------------|---------|
Use a lightweight baseline to expose column failures
A text baseline can recover ordinary prose while corrupting the relationships between columns.
MarkItDown and PyMuPDF4LLM both preserved the ordinary prose sample in our local runs. On the column sample, MarkItDown alternated fragments from unrelated paragraphs. Its output began an invoice-reconciliation sentence, switched to demand forecasting, then returned to the invoice sentence. An application can ingest this text without a parsing error and still receive a sentence the source never contained. 7
The tested calls use the libraries' direct conversion interfaces. MarkItDown plugins were disabled, and PyMuPDF4LLM ran with the environment's default configuration. 81011
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")
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
PyMuPDF4LLM kept the column paragraphs together on that same input. That makes layout recovery a useful next step when a text baseline has words but broken sentences. It does not establish that OCR is required for the page, because this sample already contained usable embedded text. 7
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**||
|---|---|---|---|
The table output exposes a separate limit: recovering paragraph order did not recover the grouped header as a flat set of field names. Both local paths retained the table's data rows while splitting header labels across lines. 7
| Path and failure condition | Visible symptom | Response |
|---|---|---|
| MarkItDown on our column sample | A sentence switches subjects midway through | Use a layout-sensitive path for that input |
| PyMuPDF4LLM on our grouped-header sample | Parent and leaf labels occupy separate rows | Reconstruct field names or retain suitable HTML |
| Either local configuration on our scan | No scanned sentence appears in extracted text | Add a working OCR route rather than formatting the empty result |
Keep scanned pages inside the conversion
An otherwise readable document still needs OCR when one of its pages contains only an image.
Our mixed document combined prose, a table, and a scanned page. Both local configurations returned the prose and table but omitted the scanned sentence. Their jobs did not raise an exception. Looking only at the start of the Markdown would have hidden the missing content, while searching for a sentence from the scanned page exposed it. 7
The supplied Marker output contained the scanned passage. Markovo's tested PDF to Markdown converter also contained that passage, and its supplied job snapshot records OCR activity on the mixed document. This offers an OCR-backed route relative to the local text baselines, not a demonstrated advantage over Marker. Its equation output had fewer recoverable reference expressions than the other reported paths, and its tables used HTML rather than pipe syntax. The excerpt shows why recovering scan text does not establish clean mathematical output. 79
Output from our equation sample, unedited:
$$
\[\mathsf{E}=\mathsf{m}\mathsf{c}\mathsf{^2}\]
$$
$$
\[a^{\wedge}2+b^{\wedge}2=c^{\wedge}2\]
$$
A real scan can contain regions that recognition treats differently. A historical Marker API issue reports missing headers and a stamp left as an image despite forced OCR. That report concerns a different hosted configuration and was not reproduced here. It identifies a useful acceptance target: search for text from the stamp or margin as well as the main paragraph when those regions carry information your application needs. 6
Repair table meaning before accepting the output
Fix a table at the stage where its content or relationships disappeared.
Marker's table processor attempts digital reconstruction before requesting recognition for unresolved table crops. Its HTML cleanup can reject an unusable recognition response. Its table block renderer then needs recognized table content to emit. A reported missing-table issue describes this gap: text exists in the PDF, but a table without usable HTML can lose it during output. We read the implicated source branches without reproducing the issue on its original document. 245
A separate rendering choice applies when recognized HTML already exists. Marker can retain that HTML inside Markdown. Its pipe-table conversion instead expands cell spans into a grid, leaving blank positions around spanning cells. Keeping HTML can preserve relationships already represented in those cells. It cannot recreate a header or table that recognition never supplied. 3
| Observed state | Diagnosis | Repair |
|---|---|---|
| Table words absent before Markdown rendering | Extraction or table recognition lost content | Change recognition settings or reconstruct from source text |
| HTML has grouped headers, Markdown has blanks or ambiguous labels | Rendering flattened the recognized structure | Retain HTML if the consumer supports it, or name each field explicitly |
| Values remain but their headers are detached | Data presence has survived without usable field labels | Attach the parent and leaf meaning to each field before ingestion |
Our grouped-header sample is a reason to examine the header independently from the data rows. The local outputs retained values beneath a split heading. The supplied Marker output joined words into a flat heading, while Markovo retained an HTML table. None of those observations establishes that the original group relationship survived unchanged. 7
Use a text baseline for intact prose, layout recovery for interleaved columns, and OCR for missing image text. Repair table labels after recognition, preserving HTML only where the downstream reader accepts it.
How we tested
These results describe small synthetic English PDFs and the configurations recorded here, not a ranking for arbitrary documents.
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.
Testing and scoring date: 2026-10-30. Local Python: 3.11.14. The supplied upstream manifest also records Python 3.11.14, without an independently captured runtime log. This guide uses the eight PDF samples below. It does not evaluate the Office, web or media inputs included elsewhere in the supplied corpus.
| File | Represents |
|---|---|
lab/corpus/prose-single.pdf |
Ordinary embedded prose |
lab/corpus/two-column.pdf |
Parallel columns with distinct paragraphs |
lab/corpus/table-simple.pdf |
A ruled table with a flat header |
lab/corpus/table-merged-header.pdf |
A parent heading over price columns |
lab/corpus/rotated-header.pdf |
Table labels rotated on the page |
lab/corpus/scan-image.pdf |
Clean image-only English text |
lab/corpus/mixed.pdf |
Prose, table and image-only pages together |
lab/corpus/equation.pdf |
Simple linear expressions, not complex typeset mathematics |
Each PDF has a same-stem .truth.json reference. The plain expressions in the equation sample cannot establish accuracy on fractions, stacked scripts or handwritten mathematics. A high score on this sample can come from text extraction alone.
Execution provenance and options
| Path | Version recorded | Execution boundary |
|---|---|---|
| MarkItDown | 0.1.8 | Run locally for this guide with enable_plugins=False. |
| PyMuPDF4LLM | 1.28.2 | Run locally with default to_markdown. No additional OCR setup |
| Marker | 2.0.0 | Supplied upstream cached output, device recorded as MPS. original command and mode were not supplied. not rerun here |
| Markovo | Service build not supplied | Upstream API output and succeeded job snapshots. 2026-09-19.v3-media-unified is the recorded rate-card version, not a software build |
Marker was not executed for this guide. Its OCR conditions were checked against the pinned upstream documentation. We did not run an OCR-disabled versus OCR-enabled Marker comparison. Historical issues are reports, not local reproductions. No T4 reproduction claim is made.
The local runner uses these same calls shown in the article: MarkItDown(enable_plugins=False).convert_local(str(path)).markdown and pymupdf4llm.to_markdown(str(path)). Complete commands, from the repository root:
tools/lab-venv/bin/python tools/lab_run.py --lab runs/2026-10-30-markovo-marker-pdf-to-markdown/lab --tools markitdown,pymupdf4llm --samples prose-single,table-simple,table-merged-header,two-column,scan-image,equation,rotated-header,mixed --heavy-cache '' --markovo-cache ''
tools/lab-venv/bin/python tools/lab_score.py --lab runs/2026-10-30-markovo-marker-pdf-to-markdown/lab --out runs/2026-10-30-markovo-marker-pdf-to-markdown/lab/scores.json --markdown runs/2026-10-30-markovo-marker-pdf-to-markdown/lab/scores.md
A standalone copy of the exact scorer used is lab/scorer.py. With the published lab directory downloaded, scoring can also be repeated with:
python lab/scorer.py --lab lab --out lab/scores.json --markdown lab/scores.md
Re-running the local conversions does not reconstruct the unknown upstream Marker command. The supplied API route is recorded as POST /v1/convert, then job polling and Markdown download in the runner contract. This sandbox made no fresh API requests. Cached upstream timings were supplied as zero, so we preserved that input in lab/runs-input-snapshot.json and changed those timing entries to null in lab/runs.json before scoring. They are missing measurements, not zero-second conversions. Local per-sample timings exclude initial library import and are not comparable with the upstream cache entries. We make no speed claim.
Scoring definitions and blind spots
The definitions follow tools/lab_score.py, copied as lab/scorer.py. Text normalization lowercases, folds whitespace and removes Markdown pipes, asterisks, backticks and heading marks. prose_recall and scan_text_recall count normalized reference sentences containing at least four words. reading_order tests the positions of each paragraph's first six words for increasing adjacent order, with absent anchors counting as failures.
table_rows_intact counts reference rows whose cell strings all appear on an output line. It does not check cell order or prove semantic grouping. table_header_intact checks whether all leaf labels occur together on an output line. It does not test parent headings, rowspan or colspan semantics. table_pipe_detected detects pipe-table syntax, not table quality. HTML is not a failed table merely because this flag is false. Our Markovo HTML is serialized on a line, which can satisfy the line-based checks without demonstrating correct cell relationships.
pages_covered is a mixed-document anchor ratio, not physical pagination recovery. equation_recall normalizes math delimiters and selected LaTeX wrappers before substring matching. equation_recall_exact uses the narrower text normalization. Neither measure checks mathematical equivalence or rendered appearance. elapsed_s passes through the available runner measurement, and error passes through recorded exceptions. n/a means null or not applicable, not zero. All reported error cells are null.
Recorded PDF results
The table is generated directly from lab/scores.json, retaining every applicable PDF metric for the four discussed paths. Other supplied tools and non-PDF samples remain in the full score file but are outside this article's comparisons. 78
| 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 | error |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| markitdown | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.0323 | n/a |
| markitdown | two-column | 0.1111 | 0.8 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.0362 | n/a |
| markitdown | table-simple | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | n/a | 0.0193 | n/a |
| markitdown | table-merged-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | n/a | 0.015 | n/a |
| markitdown | rotated-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | n/a | 0.0141 | n/a |
| markitdown | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | n/a | 0.0101 | n/a |
| markitdown | mixed | 1.0 | n/a | 1.0 | 1.0 | true | 0.6667 | 0.0 | n/a | n/a | 0.0234 | n/a |
| markitdown | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | 1.0 | 0.0558 | n/a |
| pymupdf4llm | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.1875 | n/a |
| pymupdf4llm | two-column | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.1496 | n/a |
| pymupdf4llm | table-simple | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | n/a | 0.1602 | n/a |
| pymupdf4llm | table-merged-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | n/a | 0.1653 | n/a |
| pymupdf4llm | rotated-header | n/a | n/a | 1.0 | 0.0 | true | n/a | n/a | n/a | n/a | 0.1768 | n/a |
| pymupdf4llm | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 0.0 | n/a | n/a | 0.2081 | n/a |
| pymupdf4llm | mixed | 1.0 | n/a | 1.0 | 1.0 | true | 0.6667 | 0.0 | n/a | n/a | 0.3603 | n/a |
| pymupdf4llm | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.75 | 0.75 | 0.2045 | n/a |
| marker | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a |
| marker | two-column | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a |
| marker | table-simple | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | n/a | n/a | n/a |
| marker | table-merged-header | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | n/a | n/a | n/a |
| marker | rotated-header | n/a | n/a | 1.0 | 1.0 | true | n/a | n/a | n/a | n/a | n/a | n/a |
| marker | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | n/a | n/a | n/a | n/a |
| marker | mixed | 1.0 | n/a | 1.0 | 1.0 | true | 1.0 | 1.0 | n/a | n/a | n/a | n/a |
| marker | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | 0.75 | n/a | n/a |
| markovo | prose-single | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a |
| markovo | two-column | 1.0 | 1.0 | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a |
| markovo | table-simple | n/a | n/a | 1.0 | 1.0 | false | n/a | n/a | n/a | n/a | n/a | n/a |
| markovo | table-merged-header | n/a | n/a | 1.0 | 1.0 | false | n/a | n/a | n/a | n/a | n/a | n/a |
| markovo | rotated-header | n/a | n/a | 1.0 | 1.0 | false | n/a | n/a | n/a | n/a | n/a | n/a |
| markovo | scan-image | n/a | n/a | n/a | n/a | n/a | n/a | 1.0 | n/a | n/a | n/a | n/a |
| markovo | mixed | 1.0 | n/a | 1.0 | 1.0 | false | 1.0 | 1.0 | n/a | n/a | n/a | n/a |
| markovo | equation | n/a | n/a | n/a | n/a | n/a | n/a | n/a | 0.5 | 0.0 | n/a | n/a |
The supplied Markovo snapshots record pages_total=3, pages_ocr=3 for mixed, and pages_total=1, pages_ocr=1 for scan-image and equation. In the mixed snapshot, job.billing_metrics.pagesOcr=1 and the queued response lists ocrPageIndexes=[2], while job.pages_ocr is 3. These supplied fields disagree about the OCR count, so they establish reported OCR activity without identifying a reconciled final page count or proving recognition accuracy. The API job timestamps are from 2026-10-06. 2026-10-30 is the cache-import and local scoring date, not a fresh service run. The equation row records Markovo's normalized match result of 0.5 alongside the local paths and Marker, including their higher results. No commercial terms are inferred from the snapshots. 9
Raw Markdown outputs are lab/<tool>/<sample>.md, with tool and sample names exactly as in the table. lab/output-excerpts.md preserves their first twenty lines. The supplied Markovo responses are lab/markovo/<sample>.json. the run-level record is lab/markovo/run.json. The source samples, truth files, outputs, manifest and scorer support inspection of each claim rather than a single aggregate score.
Sources
- Marker v2.0.0 README
- Marker v2.0.0 table processor
- Marker v2.0.0 Markdown renderer
- Marker v2.0.0 table block
- Missing table text report, not reproduced here
- Historical hosted OCR failure report, not reproduced here
- Recorded scores and metric definitions
- Execution manifest and provenance boundaries
- Supplied Markovo mixed-document job snapshot
- MarkItDown official repository
- PyMuPDF4LLM official documentation


