LlmFiles
Converter to Markdown
LlmFiles/Guides/PDF to Markdown

PDF to Markdown

A PDF says where each glyph sits on a page; it never says which line is a heading. So PDF to Markdown is extraction, not translation, and most of the work happens after it. Here is what you get, measured on a real paper, where it breaks, and how to repair it before a model reads the text.

Updated 23 Oct 2026 · LlmFiles

A PDF has no headings

The format keeps a text layer: glyphs placed at x/y coordinates, plus a font table. Paragraphs, list markers and heading levels are not stored anywhere — they are visual effects of position, size and weight. Any tool that returns # headings from an arbitrary PDF is inferring them, and the inference fails often enough to notice: a 14pt bold subtitle and a 16pt section title look the same to a heuristic, and a report set in one style has no levels left to recover.

Check the file before converting: pdftotext -f 1 -l 2 report.pdf - | head. Text appears — there is a text layer. Empty output — the pages are images and need OCR.

What LlmFiles returns for a PDF

Our pipeline reads the text layer and returns plain Markdown text in reading order. Paragraph breaks survive, a page break stays a form-feed character, a section title arrives as its own line but without any #, tables arrive as a column of cell values, and a URL that is printed in the text stays as text. Hyperlinks are not turned into [text](url), because in a PDF the link lives in an annotation object outside the text stream.

Measured on the Transformer paper (15 pages, 2.2 MB): 39,662 characters, 5,868 words, 14 page breaks, no heading markers and no pipe tables. Its Table 1 comes out like this:

Layer Type
Complexity per Layer
Self-Attention
Recurrent
Convolutional
Self-Attention (restricted)
O(n2 · d)
O(n · d2)
O(k · n · d2)
O(r · n · d)
Sequential Maximum Path Length
Operations
O(1)
O(n)
O(1)
O(1)

Nothing is invented there: no line is promoted to a heading because it is bold, no cell is dropped, no character is guessed by OCR. That is the honest ceiling of text-layer extraction — a good starting point, not finished Markdown.

The five PDFs that break it

What you seeCauseWhat to do
Empty result, or a single line of nothingThe pages are images: no text layer at allOCR first: ocrmypdf --skip-text in.pdf out.pdf, then convert
A column of single letters and digitsRotated or marginal text, like a publisher stampCrop the margin and convert the clean version
Half-sentences from two different columnsTwo-column layout: stored order is not reading orderSplit the pages, or compare with pdftotext -layout output
Garbage instead of accents, dashes or mathsEmbedded font without a usable ToUnicode mapConvert the source DOCX, or OCR the file
The table turned into a list of valuesCells are separate text runs; no row or column markersRebuild from XLSX/CSV, or paste by hand

A workflow that ends in usable Markdown

  1. Check for an editable source. DOCX, EPUB and XLSX keep structure; the PDF is only their print image.
  2. Look at the text layer with pdftotext. No text — the file needs OCR.
  3. OCR the scan so the pages get a real text layer instead of a picture of words.
  4. Convert: drag the file into the converter, or call the API from a script.
  5. Repair the structure — headings, running heads, page numbers — before the text goes anywhere else.
  6. Chunk on the headings you just restored — that is the reason to do this at all.
# 1. is there a text layer at all?
pdftotext -f 1 -l 3 report.pdf - | head -20

# 2. scanned? build a text layer, keep the original pages as they are
ocrmypdf --skip-text report.pdf report-ocr.pdf

# 3. convert, either in the UI or over the API
curl -s -X POST https://llmfiles.com/api/convert \
  -F "[email protected]" | jq -r .markdown > report.md

Restoring heading levels with a script

Numbered documents — standards, contracts, specs, papers — give their structure away: headings start with a number. A few lines of Python put the # back:

import re, sys

path = sys.argv[1]
lines = open(path, encoding="utf-8").read().split("\n")
out = []
for line in lines:
    t = line.strip()
    m = re.fullmatch(r"(\d+(?:\.\d+)*)\.?\s+([A-Z][^.]{2,80})", t)
    if m:
        depth = min(m.group(1).count(".") + 2, 4)
        out.append("#" * depth + " " + t)
    else:
        out.append(line)
open(path, "w", encoding="utf-8").write("\n".join(out))

Run it on a copy and read the diff: it is a heuristic. On numbered legal or technical documents it usually works; on a magazine layout it produces nonsense.

Review the first twenty headings by hand: a wrong # becomes a wrong chunk boundary, and that becomes a confident wrong answer months later.

Cleanup before the text goes to a model

These five edits do more for retrieval than swapping the embedding model: they decide what ends up in a chunk.

When the PDF is the wrong input

If you control the document, do not convert the PDF at all. Word files keep heading styles, EPUB keeps chapters, spreadsheets keep cells; a PDF keeps coordinates. Ask for the source and convert that — and use the PDF only when it is all there is, which is the usual case with signed contracts, vendor docs and scanned archives. The converter takes 24 formats, so this route is rarely extra work.

Privacy, limits and the API

Files are parsed on our server: a temporary copy exists for the duration of the conversion and is deleted straight after. Nothing is sent to third-party OCR or LLM APIs, and file contents never reach analytics — only an event name and a hashed IP. For documents under NDA there is an on-prem install inside your own network.

Free tier: 5 files a day from one IP, up to 25 MB each, no sign-up and no account. Pro (300 MB, no daily cap) is not on sale yet — we are not taking money for it. Leave a request on the pricing page and we will write to you when launch pricing opens. For pipelines there is POST /api/convert, which returns JSON with a markdown field, and POST /api/convert/zip, which takes a ZIP and returns a ZIP with one .md per file. Working curl, Python and JavaScript examples are in the API documentation.

Frequently asked questions

Does the converter give me real headings from a PDF?

No: heading levels are not stored in a PDF. Titles arrive as their own lines without # — add levels with the script above, or convert the source DOCX.

Can it read a scanned PDF?

No OCR in the pipeline: a page without a text layer returns nothing, and the API dumps the raw bytes instead. Run ocrmypdf or tesseract first.

Are tables converted into Markdown tables?

Not from a PDF — cells come out as separate lines in stored order, though the caption survives. Convert the XLSX or CSV source for real pipe tables.

Why are the links gone?

PDF hyperlinks are annotations outside the text stream, so extraction does not see them. A printed URL stays as text; the clickable annotation does not.

Is the extracted text accurate?

Character for character, yes: it is your text layer, not a guess. Layout is another matter — columns, rotated stamps and merged cells can arrive in the wrong order.

What are the limits, and can I automate this?

Free: 5 files a day from one IP, up to 25 MB. Pro is not being sold yet. Automation works today via POST /api/convert and POST /api/convert/zip.

Convert a file

Drag a document into the window and get Markdown you can read, diff and feed to a model. Free: 5 files a day, up to 25 MB.