LlamaIndex OCR for scanned PDFs, tested
0 of 1,955
words SimpleDirectoryReader found in a scan
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
| 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.
Or send us the scan. Tack, our AI, does the job for $29, up to 50 pages. First 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.