Why DOCX converts better than PDF
In a PDF the structure is a side effect of position and font size, so a converter has to guess. In a DOCX the structure is data: every paragraph points at a style, every list points at a numbering definition, every table declares its rows and cells. A converter reads those pointers instead of guessing, which is why the output has real # headings and real pipe tables. If you have the Word file, convert it instead of its PDF export — that advice alone fixes most bad Markdown.
What LlmFiles returns for a DOCX
The pipeline is: Word styles and numbering are read, converted to HTML, then to Markdown. Here is the actual output for a 4.6 KB report — a title, a subtitle, a bold line that is not a heading, two lists, a table, a footnote and one image (926 characters, 36 lines):
# Quarterly Report
Prepared for the board, Q3 2026.
**Results**
Revenue grew **12%** quarter over quarter, and *churn* fell. See [the dashboard](https://llmfiles.com/pricing) for details.
* First bullet about pipeline
* Second bullet with a nested item
+ Nested bullet under the second
1. Open the config file.
2. Set the timeout to 30 seconds.
## Numbers by region
| | |
| --- | --- |
| **Region** | **Revenue** |
| EMEA | 48.2 |
A footnote marker sits here[[1]](#footnote-1) and the sentence ends.

1. Numbers are unaudited, see appendix B.
Note what happened in that excerpt: the heading levels came from the styles, the bold paragraph stayed a bold paragraph, the footnote moved to the bottom as a numbered list item, and the picture turned into a stub. The empty header row in the middle of it gets its own section below.
The mapping, measured element by element
Each row of this table was checked on a generated file and on Word documents saved by Word itself, not taken from documentation:
| Word element | Markdown you get | Note |
|---|---|---|
| Heading 1 … Heading 6 style | # … ###### | Exact. The level comes from the style, never from the font size |
| Bold, italic, strikethrough runs | **b**, *i*, ~~s~~ | Exact |
| Hyperlink | [text](url) | Exact, including external links and internal anchors |
| Bulleted and numbered lists | * item and 1. item, nesting by two spaces | Numbers are renumbered from one; nested level comes out as + |
| Footnote and endnote | [[1]](#footnote-1) plus a numbered list at the end | Both markers and text survive, but they land at the bottom of the file |
| Table | Pipe table with a separator row | Rows and columns survive, the header row usually does not — see below |
Merged cells (gridSpan) | Extra empty cell | Column count is preserved; a horizontal merge is expressed as a blank cell |
| Text box | Its text as a paragraph | Content is not lost |
| Bold paragraph in the Normal style | **text** | Bold is not a heading. If the author styled titles by hand, fix them afterwards |
| Monospace font (Consolas, Courier) | Plain text | No backticks: a font is not a style |
Title and Subtitle styles | Plain paragraphs | Word's dedicated title style is not mapped to # |
Quote style | Plain paragraph | No > blockquote |
| Tabs inside a paragraph | A single space | Columns of an old-style layout collapse |
| Shift+Enter line break | A plain newline | Not a Markdown hard break — add two spaces if you need one |
What does not survive
These are the losses, measured on purpose-built files and on Word's own samples:
| What disappears | What you actually get | Why |
|---|---|---|
| Images |  | A text placeholder, not the picture: the alt text is kept, the bytes are not |
| Comments | Nothing at all | Reviewer notes are a separate part of the file and are skipped |
| Tracked changes | Insertions kept, deletions dropped, no markup | The converter silently shows the accepted version |
| Headers and footers | Nothing | So no "Confidential" stamp and no page numbers |
| Auto-numbered headings | # Introduction without the number | The number is generated by Word at render time, it is not in the text |
| A table of contents field | Its cached text as one line | The TOC is a field with a stored preview, links included are lost |
Legacy .doc | A code block of raw bytes | The old binary format is not supported — save as .docx first |
The empty header row, and how to fix it
This is the defect people notice first. A Word table has no header cells — a row is just cells that happen to be bold. So the converter cannot know which row is the header, and Markdown requires a header row. The result is an empty one, followed by the separator, followed by your data:
| | |
| --- | --- |
| Top left | Top right |
| Bottom left | Bottom right |
There is one way to fix it at the source: in Word select the first row, open Table Properties, Row, and tick Repeat as header row at the top of each page. Only that flag turns the row into a header cell — measured: the look-and-feel option firstRow alone changes nothing in the output. If you would rather not touch the document, run this over the Markdown instead. It moves the first data row up into the header and leaves tables that already have one alone:
import re, sys
SEP = re.compile(r"^\|(\s*:?-{3,}:?\s*\|)+$") # | --- | --- |
EMPTY = re.compile(r"^\|(\s*\|)+$") # | | |
def is_row(line):
return line.strip().startswith("|") and line.strip().endswith("|")
def fix_tables(md):
lines = md.split("\n")
out, i, fixed = [], 0, 0
while i < len(lines):
line = lines[i]
if (is_row(line) and EMPTY.match(line.strip())
and i + 2 < len(lines)
and SEP.match(lines[i + 1].strip())
and is_row(lines[i + 2]) and not EMPTY.match(lines[i + 2].strip())):
out.append(lines[i + 2]) # first data row becomes the header
out.append(lines[i + 1]) # separator row stays
i += 3
fixed += 1
continue
out.append(line)
i += 1
return "\n".join(out), fixed
for path in sys.argv[1:]:
text = open(path, encoding="utf-8").read()
fixed_text, n = fix_tables(text)
if n:
open(path, "w", encoding="utf-8").write(fixed_text)
print(f"{path}: {n} table(s) fixed")
It was run on a Word-produced sample and on the report above: one table fixed in each, and a table that already had a real header row came through unchanged.
A pipe inside a cell breaks the whole row
Markdown separates cells with |, and nothing escapes it for you. A cell containing a|b turns a two-column row into three columns, and every row below it shifts:
| Format | Files |
| --- | --- |
| a|b\*c\_d | 3 | <- three cells instead of two
Asterisks and underscores are escaped automatically (that \* and \_ are the converter's own work), pipes are not. Replace | with / in the source, or escape it as \| in the Markdown afterwards.
Lists inside table cells collapse
A cell that holds a bullet list has no line structure in the output — the items are joined into the cell text: Cell with a list * item one * item two. If the documents you convert look like that, take the lists out of the cells before converting, or split the table into one table per list.
Repair pass before the text goes to a model
- Fix the header rows — the script above, one pass over the whole folder.
- Replace pipes inside values, or the tables mislead a reader and a parser alike.
- Promote the fake headings: if the author used bold instead of styles, lines that are only
**text**are probably your section titles. Turn them into## text— carefully, some of them are genuinely just emphasised paragraphs. - Delete the image stubs (
) — they carry no information and they teach a retriever nothing. - Keep one
#per document and push everything else down a level, so chunk boundaries mean something. - Move the footnote list where the footnote is, if the document is read in fragments rather than top to bottom.
Privacy, limits and the API
Files are parsed on our server: a temporary copy exists for the duration of the conversion and is deleted right after it. Nothing is sent to third-party OCR or LLM services, 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 when launch pricing opens. For pipelines there is POST /api/convert (JSON with a markdown field), POST /api/convert/batch (up to 20 files in one request) and POST /api/convert/zip (a ZIP in, a ZIP of .md files out). Working examples are in the API documentation.