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.
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 see | Cause | What to do |
|---|---|---|
| Empty result, or a single line of nothing | The pages are images: no text layer at all | OCR first: ocrmypdf --skip-text in.pdf out.pdf, then convert |
| A column of single letters and digits | Rotated or marginal text, like a publisher stamp | Crop the margin and convert the clean version |
| Half-sentences from two different columns | Two-column layout: stored order is not reading order | Split the pages, or compare with pdftotext -layout output |
| Garbage instead of accents, dashes or maths | Embedded font without a usable ToUnicode map | Convert the source DOCX, or OCR the file |
| The table turned into a list of values | Cells are separate text runs; no row or column markers | Rebuild from XLSX/CSV, or paste by hand |
A workflow that ends in usable Markdown
- Check for an editable source. DOCX, EPUB and XLSX keep structure; the PDF is only their print image.
- Look at the text layer with
pdftotext. No text — the file needs OCR. - OCR the scan so the pages get a real text layer instead of a picture of words.
- Convert: drag the file into the converter, or call the API from a script.
- Repair the structure — headings, running heads, page numbers — before the text goes anywhere else.
- 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.
# becomes a wrong chunk boundary, and that becomes a confident wrong answer months later.Cleanup before the text goes to a model
- Page numbers and running heads. Drop lines that are only digits, and the line that repeats at the top of every page.
- Hyphenated line breaks. Replace
(\w)-\n(\w)with\1\2, or the embeddings gettrans- formerinstead oftransformer. - Form feeds. Invisible to you, fatal to naive splitters — replace them with a blank line.
- Reference lists. Noise for retrieval unless citations are the question you are asking.
- One
#per document — the title — and everything else from##down.
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.