Extract text from multi-column PDFs in Python (in the right reading order)
Direct answer: Multi-column PDFs break because most extractors read text in the order it was written to the file, not the order a human reads it. A two-column page gets returned as a line from the left column, then a line from the right column, then back to the left — interleaved into nonsense. The fix is reading-order reconstruction: detect the column boundaries, group text into columns, then emit each column top-to-bottom, left-to-right. You can do this manually by clustering text spans on their x-coordinates with pdfplumber, or you can run the file through pdfmux with process("paper.pdf"), which detects multi-column layout and returns text in correct reading order automatically. If your downstream is a RAG pipeline or an LLM, the scrambling is invisible until your answers go wrong — which is exactly why it’s so dangerous.
What actually goes wrong
A PDF does not store “paragraphs.” It stores a sequence of text-drawing operations — “place this glyph run at coordinates (x, y).” The order of those operations is whatever the authoring tool happened to emit, and for a two-column academic paper or newspaper page, that order frequently zig-zags across the columns.
Take a research paper with two columns. The visual reading order is: all of the left column top-to-bottom, then all of the right column. But a naive page.extract_text() may return:
Left column, line 1
Right column, line 1
Left column, line 2
Right column, line 2
...
The result is a sentence from the left column spliced mid-thought into a sentence from the right. To a human skimming it, it’s obviously broken. To an LLM ingesting it for RAG, it’s just text — and the model will confidently answer questions using sentences that were never adjacent in the original. This is one of the most common silent corruptions in document pipelines, and it never throws an error.
Why naive extraction interleaves columns
The single-column case works fine, which is what fools people. On a single-column page, file order and reading order usually coincide, so extract_text() looks reliable. Move to two or three columns and the assumption collapses.
Three layouts where reading order breaks most often:
- Academic papers — the dominant two-column format on arXiv and in most journals. Dense, with figures and equations that further fragment the text stream.
- Newspapers and magazines — three to five columns, pull quotes, sidebars, captions threaded between body text.
- Financial and legal documents — two-column terms, side-by-side comparisons, footnote columns. Here a scramble can change the meaning of a clause, not just its readability.
Detecting columns: the x-coordinate approach
The robust signal for column structure is the x-coordinate of each word. In a two-column layout, word x-positions cluster into two bands with a gap between them — the gutter. Find the gutter, and you’ve found the column boundary.
pdfplumber gives you every word with its bounding box, so you can cluster on the x0 (left edge) values:
import pdfplumber
def detect_columns(page, gap_threshold=40):
"""Return x-boundaries of columns by finding gaps in word x-positions."""
words = page.extract_words()
if not words:
return []
# Sort the left edges of every word
x_starts = sorted(w["x0"] for w in words)
# A large jump between consecutive x0 values marks a gutter
boundaries = []
for prev, curr in zip(x_starts, x_starts[1:]):
if curr - prev > gap_threshold:
boundaries.append((prev + curr) / 2)
return boundaries
with pdfplumber.open("paper.pdf") as pdf:
page = pdf.pages[0]
print("Column gutters at x =", detect_columns(page))
This is a heuristic, not a guarantee. gap_threshold needs tuning per document — too low and a wide word-space looks like a gutter; too high and you miss narrow columns. Tables, figures, and full-width headers also confuse pure x-clustering. But for clean two-column body text it gets you most of the way.
Reconstructing reading order manually
Once you know the gutters, assign each word to a column, then sort within each column top-to-bottom and concatenate columns left-to-right:
import pdfplumber
def extract_in_reading_order(page, gutter_x):
"""Two-column reconstruction: left column fully, then right column."""
words = page.extract_words()
left = [w for w in words if w["x0"] < gutter_x]
right = [w for w in words if w["x0"] >= gutter_x]
def assemble(col_words):
# Group into lines by rounded vertical position, then sort
lines = {}
for w in col_words:
key = round(w["top"] / 3) # tolerance for baseline wobble
lines.setdefault(key, []).append(w)
out = []
for key in sorted(lines):
row = sorted(lines[key], key=lambda w: w["x0"])
out.append(" ".join(w["text"] for w in row))
return "\n".join(out)
return assemble(left) + "\n\n" + assemble(right)
with pdfplumber.open("paper.pdf") as pdf:
text = extract_in_reading_order(pdf.pages[0], gutter_x=300)
print(text)
This works, and for a known, consistent layout it’s worth doing. But notice everything it hard-codes: a single gutter, exactly two columns, no figures spanning the full width, no footnotes in a third column, no page where the layout shifts from two columns to one. Every real corpus violates at least one of those assumptions, and each violation is a new branch you maintain by hand.
The pdfmux approach: layout-aware by default
pdfmux treats reading order as part of the extraction problem, not a post-processing afterthought. It analyzes the page layout — detecting columns, headers, and the gutter automatically — and emits text in human reading order without you tuning a threshold:
from pdfmux import process
result = process("two-column-paper.pdf", quality="standard")
print(result.markdown) # already in correct reading order
print("Confidence:", f"{result.confidence:.0%}")
The differences that matter for a multi-column corpus:
- No per-document tuning. You don’t set
gap_thresholdorgutter_xper file. The same call handles a two-column paper, a three-column newsletter, and a single-column memo. - Confidence as a tripwire. When layout reconstruction is uncertain — an unusual grid, a heavily figure-broken page —
result.confidencedrops, so you can route low-scoring pages to review instead of trusting scrambled output. A manualpdfplumberscript has no equivalent; it returns interleaved text with the same confidence as clean text. - Markdown that preserves structure. Output comes back as clean Markdown with headings and paragraph breaks intact, which is what an LLM wants to ingest.
pdfplumber vs pdfmux for multi-column work
| Concern | Manual pdfplumber | pdfmux |
|---|---|---|
| Column detection | You write x-clustering | Automatic |
| Threshold tuning | Per-document | None |
| 3+ columns | More custom code | Handled |
| Full-width figures/headers | Breaks naive clustering | Detected |
| Reading-order guarantee | Only your tested layouts | Layout-aware default |
| Confidence signal | None | 0.0–1.0 per page |
| Scanned multi-column pages | Returns nothing usable | OCR + layout |
| Lines of code | 40+ and growing | 2 |
For a one-off script against a single fixed template, the pdfplumber route is fine and gives you total control. For a pipeline that ingests papers, reports, or articles from many sources, the maintenance cost of hand-rolled column logic compounds fast. The comparison generalizes — see pymupdf vs pdfplumber for the broader trade-off between the low-level libraries.
Verifying you got reading order right
Whichever route you take, verify — don’t assume. Two cheap checks catch most scrambling:
- Sentence continuity. Pick a paragraph from the output and read it. If a sentence ends mid-clause and the next words are an unrelated topic, you have interleaving. Scrambled text reads like two conversations transcribed onto the same line.
- Punctuation flow. In correctly ordered text, sentences end with terminal punctuation before a new capitalized sentence begins. Interleaved columns produce runs where a lowercase fragment follows a period with no logical break.
For automated pipelines, gate on result.confidence and spot-check the lowest-scoring 5% of pages. Those are where multi-column reconstruction is most likely to have failed, and a few minutes of review there protects everything downstream.
The takeaway
Multi-column extraction is not a niche edge case — it’s the default shape of academic papers, news, and a large share of business documents. The danger is that it fails silently: no exception, no warning, just text in the wrong order that an LLM treats as gospel. Detect columns by clustering x-coordinates if you control the layout, or hand the problem to a layout-aware extractor like pdfmux if you don’t. Either way, the non-negotiable step is verifying reading order before the text reaches your index — because by the time a wrong answer surfaces, the scramble is three steps upstream and invisible.
Next, see PDF chunking strategies for RAG for keeping that correctly-ordered text intact through the chunking stage.