Tools,

LlamaIndex OCR for scanned PDFs, tested

0 of 1,955

words SimpleDirectoryReader found in a scan

Get the reader code

A scanned PDF gives 10 empty pages

On a 10-page scanned book, SimpleDirectoryReader gave no error:

docs = SimpleDirectoryReader("pdfs").load_data()
len(docs)     # 10
docs[0].text  # ''

Its default PDFReader reads the PDF's text layer, and a scan has none. PyMuPDFReader gave the same 10 empty pages.

The index builds and holds nothing

VectorStoreIndex took the 10 empty pages with no error and made 10 nodes. On the same scan, LangChain's FAISS failed instead.

The retriever gave 4 nodes with 0 characters each. The query engine sent the LLM 2 nodes: a page label and a file path, no text.

JPG scans: ImageReader keeps no text

SimpleDirectoryReader sends .jpg and .png files to ImageReader. By default it reads no text: 10 ImageDocuments, 0 characters each.

With parse_text=True alone, it stopped here:

ImportError: Please install extra dependencies that are required for the ImageCaptionReader: `pip install torch transformers sentencepiece Pillow`

That path loads a Donut model, not Tesseract.

Turn on Tesseract for images

With Tesseract installed (brew install tesseract or apt install tesseract-ocr), set the text type:

from llama_index.core import SimpleDirectoryReader
from llama_index.readers.file import ImageReader

reader = ImageReader(parse_text=True, text_type="plain_text")
docs = SimpleDirectoryReader(
    "scans",
    file_extractor={".jpg": reader, ".png": reader},
    raise_on_error=True,
).load_data()

1,879 of 1,955 true words found

10 ImageDocuments, one a scan

Scanned PDFs: a small Tesseract reader

Render each page with PyMuPDF, then run Tesseract:

import pymupdf
import pytesseract
from llama_index.core import Document, SimpleDirectoryReader
from llama_index.core.readers.base import BaseReader
from PIL import Image


class TesseractPDFReader(BaseReader):
    def load_data(self, file, extra_info=None, **kwargs):
        docs = []
        with pymupdf.open(file) as pdf:
            for n, page in enumerate(pdf, start=1):
                pix = page.get_pixmap(dpi=300, colorspace="gray")
                img = Image.frombytes("L", (pix.width, pix.height), pix.samples)
                text = pytesseract.image_to_string(img, config="--dpi 300")
                meta = {**(extra_info or {}), "page_label": str(n)}
                docs.append(Document(text=text, metadata=meta))
        return docs


docs = SimpleDirectoryReader(
    "pdfs",
    file_extractor={".pdf": TesseractPDFReader()},
    raise_on_error=True,
).load_data()

1,881 of 1,955 true words found

10 Documents, one a page

Set raise_on_error, or lose files quietly

I ran both snippets with no tesseract on the PATH. Without the flag, SimpleDirectoryReader printed a line and returned 0 Documents:

Failed to load file pdfs/scan.pdf with error: tesseract is not installed or it's not in your PATH. See README file for more information.. Skipping...

With raise_on_error=True, the load stopped with Exception: Error loading file, raised from TesseractNotFoundError.

Six setups, one scan

10 scanned pages, 1,955 true words
Setup Words found Seconds Nodes
PDFReader, default 0 3.0 10
PyMuPDFReader 0 2.7 10
ImageReader, default 0 2.4 10
ImageReader, Tesseract 1,879 38.4 10
PDF reader, Tesseract 1,881 31.8 10
Our OCR Actor 1,874 146 4

Seconds include the imports, at background priority on a Mac. The Actor's 146 include its start in the cloud.

No OCR install: call the Actor

If your app runs where you cannot install Tesseract, the LlamaIndex Apify reader can run our OCR Actor:

import os

from llama_index.core import Document
from llama_index.readers.apify import ApifyActor

PDF = "https://api.apify.com/v2/key-value-stores/sTnmNrGoSsi7tLQvs/records/langchain-scan-10-pages.pdf"

reader = ApifyActor(os.environ["APIFY_TOKEN"])
docs = reader.load_data(
    actor_id="brasspage/pdf-ocr-to-markdown",
    run_input={"documentUrls": [PDF]},
    dataset_mapping_function=lambda item: Document(
        text=item["markdown"], metadata={"source": item["url"]}
    ),
)

Install llama-index-readers-apify and set APIFY_TOKEN. It found 1,874 words, in one Markdown Document for the whole PDF.

It was the slowest setup here, and the PDF has to be at a URL. It costs $0.006 a page.

How I tested it
  • Scan: the 10-page PDF of OCR in n8n, the first 10 of 55 proofread Wikisource book scans, with no text layer. The JPG test used the same 10 scans. 1,955 true words.
  • Score: true words found in order, with the scorer of the tessdata test.
  • Python 3.13, llama-index-core 0.14.25, llama-index-readers-file 0.7.0, pypdf 6.21.4, PyMuPDF 2.1, pytesseract 0.3.13 on Tesseract 5.5.4, llama-index-readers-apify 0.6.0.
  • Each setup ran in a fresh process. Nodes: SentenceSplitter with its defaults. MockEmbedding and MockLLM, so no API call.
  • The code blocks above are the files that ran. The Actor: one real run on the 10-page PDF. The printed path is shortened.
What I did not test
  • LlamaParse and the other LlamaCloud services.
  • The Donut path of ImageReader and the multimodal readers.
  • Tables, photos, handwriting, and other languages.
Sources

Two ways to skip the work

Run it yourself. Our Apify Actor turns scanned PDFs into Markdown, tables included.

Try the Actor

Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First page free.

Get one page free

Questions people ask

Why does SimpleDirectoryReader return empty text for a PDF?

The PDF is a scan. The default PDFReader reads only the text layer, and a scan has none. On my 10-page scan it gave 10 Documents with 0 characters and no error.

Why does ImageReader(parse_text=True) ask for torch and transformers?

With the default text_type, it loads a Donut model. Set text_type="plain_text" to use Tesseract through pytesseract instead.

Is there a free LlamaParse alternative for scanned PDFs?

For printed text, the Tesseract reader on this page is free and runs locally. It found 1,881 of 1,955 words on my scan. LlamaParse needs a LlamaCloud API key, so I did not test it.