Extract text from a PDF in Python: two columns, scans and speed
We drew PDFs whose right text we knew, then read them back with pypdf, PyMuPDF and pdftotext. All of them got the words. Two columns drawn row by row beat all but one, and a scanned page came back empty with no error.
Getting the words out of a PDF is the easy part. Every library we tried got every word of a plain page right. What goes wrong is the order of the lines on a page with two columns, and the page that has no text at all because it is a picture of a page.
So on 28 October 2026 we drew our own test PDFs with reportlab, where the right answer is known line by line, and read them back with pypdf 6.13.0, PyMuPDF 1.27.2.3 and poppler's pdftotext 22.02.0.
The short version#
- Plain text: every reader found every line of a 200-page book, 9,000 of 9,000.
- Two columns: a PDF does not store columns, only words at positions, in whatever order the program that made it drew them. When the left column was drawn first, pypdf, PyMuPDF and
pdftotextall read it right. When the page was drawn row by row, left line then right line, onlypdftotextand the function below did. - "Layout" modes: pypdf's layout mode, PyMuPDF's
sort=Trueandpdftotext -layoutread straight across both columns on both test pages: line 1 left, line 1 right, line 2 left. - A scanned page: pypdf and
pdftotextboth gave 0 characters for it and no error. If you do not count, you do not notice. - Speed, 200 pages:
pdftotext0.11 s, PyMuPDF 0.28 s, pypdf 0.97 s. PyMuPDF withsort=Truetook 3.84 s in our run.
The code#
It uses PyMuPDF. It reads the page word by word, splits each line where the gap is as wide as the space between two columns, and puts the pieces in reading order. It also returns the pages with almost no text, so a scan is a list you can act on, not a silent blank.
import fitz # PyMuPDF
def reading_order(page, gap=20):
"""The lines of one page as a person reads them. Words on one baseline are split where the gap is wider than
`gap` points (the space between two columns). A piece that crosses the middle (a title, a wide paragraph) ends a
section; inside a section, the left column top to bottom, then the right column."""
mid, pieces = page.rect.width / 2, []
for x0, y0, x1, y1, word, *_ in sorted(page.get_text("words"), key=lambda w: (round(w[3]), w[0])):
last = pieces[-1] if pieces else None
if last and last["y"] == round(y1) and x0 - last["x1"] < gap:
last["words"].append(word)
last["x1"] = x1
else:
pieces.append({"y": round(y1), "x0": x0, "x1": x1, "words": [word]})
out, section = [], []
for p in pieces + [None]:
if p is None or p["x0"] < mid < p["x1"]:
out += sorted(section, key=lambda s: (s["x0"] >= mid, s["y"]))
section = []
if p:
out.append(p)
else:
section.append(p)
return "\n".join(" ".join(p["words"]) for p in out)
def pdf_text(path, min_chars=20):
"""{page number: text}, and the pages with (almost) no text: scans that need OCR, not a text reader."""
pages, empty = {}, []
with fitz.open(path) as doc:
for page in doc:
pages[page.number + 1] = text = reading_order(page)
if len(text.strip()) < min_chars:
empty.append(page.number + 1)
return pages, emptypages, empty = pdf_text("scan.pdf")
print(len(pages), "pages; no text on", empty)
print(pages[1][:120])On our three-page test file that printed 3 pages; no text on [2], and page 1 began with its first line.
Two columns#
On the page drawn row by row, PyMuPDF made two blocks: the title, and one block holding both columns, line 1 left, line 1 right, and so on down. The program that made the page drew them in that order, so any order built on blocks reads across the page. Working on words avoids that: a gap of more than 20 points on one line is a column gap, not a space. A piece that crosses the middle of the page, such as a title, starts a new section, so a title above two columns stays above them.
pdftotext without -layout got both pages right on its own. If you can install poppler and need only the text, it is the quick answer, and the fastest one here.
Three columns, tables and footnotes need more than this. The test that tells you is the one we used: draw a page in the layout of your own files, with labels on the lines, and check the order you get back.
Spaces between words#
Some PDFs place every word on its own, with no space characters between them. We drew 96 words that way, with a gap of a quarter of the font size. pypdf, PyMuPDF and pdftotext all gave 96 of 96 words, split at the right places. Only pypdf's layout mode got 77 of 96 right.
A page that is a picture#
A scanned page has no text to extract, only an image. The readers do not fail on it, they return an empty string. In our file of three pages, pages 1 and 3 gave 1,311 characters each and page 2 gave 0. pdf_text() lists such pages, so you can send them to OCR, or count them before you trust a result.
Licences#
pypdf is BSD-3-Clause. PyMuPDF is AGPL 3.0, or a commercial licence from Artifex. If your code is closed and you ship it or run it as a service for others, read what the AGPL asks of you before you choose PyMuPDF.
Many PDFs, by link#
For PDFs at links, many at a time or on a schedule, our PDF Text Extractor on Apify does the reading:
- The text of each PDF page by page, as plain text or as Markdown with headings, bold, lists and paragraphs.
- The page count, title, author, dates and other metadata of each file.
- Links or uploaded files, up to 1,000 in one run.
It is billed per PDF.
written
7 min readLarge print word search: what large print means, in millimetres
Large print is generally 16 to 18 point to RNIB, and at least 16 point, ideally 18, to UKAAF. We measured our own Christmas word search book: 18-point bold grid letters, capitals 4.23 mm tall, letters 10.24 mm apart, and nothing smaller than 16 point. What that means on the page, and what else makes a puzzle book easy to read.
7 min readWord search generator in Python: the two bugs a quick one ships
Placing the words is the easy part. In 900 test grids a one-pass fill left a rude word in more than half and a listed word twice in about one in ten. What causes both, and the fill that fixes them.
5 min readWebsite screenshot in Python: what still goes wrong, measured
A screenshot with Playwright is two lines. Some old warnings about full-page shots no longer hold in today's Chromium; others do, and they cost you the images and charts on the page. We tested each one.