← blog  /  tools

Extract text from a PDF in Python: two columns, scans and speed

2026-11-07  ·  5 min read  ·  tools

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 pdftotext all read it right. When the page was drawn row by row, left line then right line, only pdftotext and the function below did.
  • "Layout" modes: pypdf's layout mode, PyMuPDF's sort=True and pdftotext -layout read straight across both columns on both test pages: line 1 left, line 1 right, line 2 left.
  • A scanned page: pypdf and pdftotext both gave 0 characters for it and no error. If you do not count, you do not notice.
  • Speed, 200 pages: pdftotext 0.11 s, PyMuPDF 0.28 s, pypdf 0.97 s. PyMuPDF with sort=True took 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.

Text from a PDF in reading order, with PyMuPDFpython
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, empty
Run itpython
pages, 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.

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