https://txtfetch.com/fixes/tables-lose-structure/
My PDF table extracted as a wall of misaligned text.
A table in a PDF usually has no structural markup at all, just a grid of text positioned to look aligned. Extraction sees the positions, not the grid.
looks-like
Name Amount Date
Alice 120 2026-01-04
Bob 75 2026-01-11
Carol 410 2026-01-19Looks like a table to a human skimming it. To a chunker or downstream parser it's indistinguishable from a run-on sentence with irregular spacing.
why-it-happens
A table in a PDF or a scanned document has no structural markup in most extraction paths. Text extraction sees a grid of text positioned at specific coordinates. Absent a real table model, it falls back to describing what it sees. That description is runs of text, separated by however many spaces happened to align the columns visually.
The row/column relationship, which number belongs to which header, exists only in that visual alignment. Any wrapping, chunking, or whitespace-normalization step downstream is free to destroy it without ever knowing it was destroying anything.
confirm-it
- Paste extracted text into the chunk previewer. Two or more space-run "column" lines in a row trips this signal specifically. Scan for space-run table columns
fix-it-yourself
Extract tables directly with a table-aware library
pdfplumber and camelot both detect table regions and return real row/column structures instead of a flattened text dump. Reach for them specifically on pages you know contain tables.
python
import pdfplumber
with pdfplumber.open("input.pdf") as pdf:
for page in pdf.pages:
for table in page.extract_tables():
print(table) # list of rows, each a list of cellsRender tables as markdown for a chunker to key off
Turning each detected table into a markdown table gives your chunker an explicit structural boundary to split on instead of guessing from whitespace.
python
def to_markdown(rows):
header, *body = rows
out = ["| " + " | ".join(header) + " |", "|" + "|".join(["---"] * len(header)) + "|"]
out += ["| " + " | ".join(r) + " |" for r in body]
return "\n".join(out)what-txtfetch-does
The important distinction is the response shape you ask for, not just the file type. The default ?format=text response flattens a table into space-separated cell values for every format. That's the shape this page is about. Pass ?format=markdown and each table comes back as a real GFM markdown table. Pass ?format=json instead, and you get a typed element tree, where a table element carries its own cells, rows, and cols. That tree also carries markdown and html renderings of the same table. Both ship on the standard tier.
For structured formats, .xlsx/.ods spreadsheets and DOCX/HTML tables, the underlying cell model genuinely exists in the file. So Tika reads real cells (resolving .xlsx shared strings and cached formula values) rather than inferring them from visual spacing. Asking for ?format=markdown or ?format=json is what carries that structure through into the response instead of flattening it. See /extract/xlsx.
For a table embedded in a PDF or a scan, there is no cell model in the file to read. Structure has to be recovered from the page. Tika's structured extraction does attempt that. On the hardest documents, it's where Tika struggles most. On the table-heavy PDF in our benchmark corpus, table cell-F1 is 0.0% at the standard tier. It reaches 100% at the premium tier (?quality=premium, a vision-language model reading the page). That premium figure covers only the 3 hardest documents in the corpus, not the whole 10-document set. Premium always routes async: a 202 response plus a job_id to poll. It falls back to the Tika baseline, flagged metadata.tier_downgraded: true, if a guardrail trips. See /benchmarks for the full methodology and caveats.
what-it-costs-you-downstream
A flattened table row reads to a chunker as an ordinary sentence with odd spacing. Column order can scramble, and a header can end up chunked away from the rows it labels. A numeric answer synthesized from that chunk loses the row/column context that made the number meaningful in the first place.
faq
- Does txtfetch preserve table structure for spreadsheets the same way it fails to for PDFs?
- A spreadsheet has a genuine advantage: its cells are a real, addressable data model. There's structure in the file to read, rather than infer from visual spacing. But you still have to ask for it. The default ?format=text response flattens a sheet into space-separated cell values. ?format=markdown (or ?format=json) is what returns real rows and columns instead. The harder problem described on this page is a table embedded in a PDF or a scan. There, no cell model exists in the file at all.
- Is there a call-time option to get real table structure from a PDF?
- Yes, two options that compose. ?format=markdown or ?format=json changes the response shape, so a recovered table comes back as a real table (cells, rows, cols) rather than flattened text. Both ship on the standard tier. ?quality=premium changes which engine reads the page instead, routing it through a vision-language model. That's the one that matters most for a hard PDF table. Our benchmark corpus measures standard-tier table cell-F1 at 0.0%, against premium's 100%, on the 3 hardest documents. See /benchmarks.
- Why does a chunker make table damage worse instead of just passing it through?
- A chunk boundary can land mid-row or mid-table, splitting a header from the rows it labels, or splitting one row across two chunks. Once that happens, no downstream step can reassemble which number belonged to which column. The structural information is gone, not just displayed oddly.
related-reading
Fix the text you already have.
The free cleaner repairs this damage in your browser. Nothing leaves the page.
Clean up your text →