PDFLoader

On this page

Module: pdf_loader · Extends BaseFileLoader

Overview

Loads PDF documents using a three-backend fallback chain, trying each in order until one succeeds. This is the only loader in the package with multi-backend redundancy, which makes it resilient to missing dependencies and to backend-specific parsing failures on malformed PDFs.

Why it exists: different PDF libraries have different strengths and weaknesses — PyMuPDF is fast and has the best Arabic/RTL text extraction, pdfplumber extracts tables well, and pypdf is a pure-Python fallback with no native dependencies. Rather than forcing a single choice, PDFLoader tries the best option first and degrades gracefully.

Signature

Python · 12 lines
class PDFLoader(BaseFileLoader):
    SUPPORTED_EXTENSIONS = [".pdf"]

    def __init__(
        self,
        file_path: str,
        config: Optional[PDFLoaderConfig] = None,
        per_page: bool = True,
        password: Optional[str] = None,
    ): ...

    def load(self) -> List[LoadedDocument]: ...

Parameters

NameTypeDefaultDescription
file_pathstrrequiredPath to the PDF file
configOptional[PDFLoaderConfig]NoneFull configuration; if omitted, built from per_page/password
per_pageboolTrueOne document per page vs. one document for the whole PDF
passwordOptional[str]NoneDecryption password

See PDFLoaderConfig for the full field set including start_page, end_page, and page_separator (only settable via config=, not as direct kwargs here).

Returns

List[LoadedDocument] — one per page if per_page=True (pages with no extractable text are skipped, not returned as empty documents), or a single document containing all pages joined by page_separator if per_page=False.

Raises

  • FileNotFoundError / ValueError — at construction, from BaseFileLoader._validate_file()
  • ValueError — "PDF is password-protected..." / "Wrong password for PDF." (fitz backend) if encrypted and no/wrong password is given
  • ValueError — "PDF is encrypted..." (pypdf backend, equivalent case)
  • RuntimeError — if all three backends fail (none installed, or all raised non-ImportError exceptions); the message includes the last error encountered and install instructions

Internal workflow — backend fallback chain

Python · 13 lines
backends = [
    ("fitz",       self._load_with_fitz),
    ("pdfplumber", self._load_with_pdfplumber),
    ("pypdf",      self._load_with_pypdf),
]
for backend_name, load_fn in backends:
    try:
        return load_fn()
    except ImportError:
        continue          # try next backend
    except Exception as e:
        last_error = e
        continue           # try next backend even on a parse error

Key behavior: any exception from a backend — not just ImportError — causes a fallback to the next backend. A backend that's installed but fails to parse a specific malformed PDF will not abort the whole operation; the next backend gets a chance. Only if every backend fails does load() raise.

See also the PDF backend fallback diagram in the architecture page.

Backend details

1. PyMuPDF (fitz) — tried first

  • Fastest backend, best Arabic/RTL text extraction.
  • Checks pdf.needs_pass and authenticates with config.password if needed.
  • Extracts PDF metadata (title, author, subject, total_pages) into every document's metadata.
  • per_page=True: iterates range(start_page, end_page or page_count), calling page.get_text("text").strip(); pages whose extracted text is empty are skipped entirely (no placeholder document is created for blank/image-only pages).
  • per_page=False: joins all non-empty page texts with page_separator.

2. pdfplumber — tried second

  • Good table extraction (though PDFLoader itself doesn't extract tables — see TableLoader for that).
  • Password passed as password=self.config.password or "".
  • Same per-page skip-if-empty and slicing behavior as the fitz backend, using pdf.pages[start:end].

3. pypdf (or legacy PyPDF2) — tried last

  • Pure-Python, no compiled dependencies.
  • Imports pypdf.PdfReader, falling back to the legacy PyPDF2.PdfReader name if pypdf isn't installed.
  • Checks reader.is_encrypted and calls reader.decrypt(password).
  • Same per-page logic, using reader.pages[start:end].

Metadata produced

Common across all backends (field names normalized): pdf_title, pdf_author, total_pages, backend (which backend actually succeeded — "fitz", "pdfplumber", or "pypdf"), plus page_number when per_page=True. The fitz backend additionally includes pdf_subject.

Complexity

  • Time: O(n) in number of pages/characters extracted; dominated by the underlying C/Rust extraction library, not by this wrapper.
  • Space: O(total extracted text) — the entire requested page range is held in memory as Python strings; there is no streaming mode for PDFs.

Thread safety

Not thread-safe for concurrent calls against the same PDFLoader instance (no internal locking), but separate instances loading separate files can safely run in different threads — this is exactly what DirectoryLoader does.

Examples

Basic — load all pages:

Python · 4 lines
from zerda.document_loaders import PDFLoader

docs = PDFLoader("report.pdf").load()
print(f"{len(docs)} non-empty pages loaded")

Whole document as a single string:

Python · 2 lines
docs = PDFLoader("report.pdf", per_page=False).load()
full_text = docs[0].page_content

Page range selection:

Python · 4 lines
from zerda.document_loaders import PDFLoader, PDFLoaderConfig

config = PDFLoaderConfig(start_page=4, end_page=10)  # pages 5–10, zero-indexed start
docs = PDFLoader("book.pdf", config=config).load()

Password-protected PDF:

Python
docs = PDFLoader("confidential.pdf", password="secret123").load()

Error handling — no backend available:

Python · 6 lines
try:
    docs = PDFLoader("scan.pdf").load()
except RuntimeError as e:
    print(f"Install a PDF backend: {e}")
    # "All PDF backends failed for scan.pdf. Install at least one of:
    #  PyMuPDF, pdfplumber, pypdf. Last error: ..."

Production usage — graceful degradation in a pipeline:

Python · 11 lines
from zerda.document_loaders import PDFLoader

def safe_load_pdf(path: str):
    try:
        return PDFLoader(path).load()
    except RuntimeError:
        logger.error(f"Skipping unreadable PDF: {path}")
        return []
    except ValueError as e:
        logger.error(f"PDF requires a password: {path} — {e}")
        return []

Best practices

  • Install PyMuPDF (pip install pymupdf) as your primary backend if your corpus includes Arabic or other RTL text — it has the strongest extraction quality among the three.
  • Use per_page=True (the default) when you plan to chunk/embed per page or need page-number citations; use per_page=False only when you genuinely want one blob per file.
  • Remember that config.extract_images exists in PDFLoaderConfig but has no effect in PDFLoader (see Configuration → caveat) — use PDFMultimodalLoader if you need OCR on embedded images.

Common mistakes

  • Assuming a page that produced no LoadedDocument means extraction failed — it more likely means the page genuinely had no extractable digital text (e.g. a scanned image page). Use PDFMultimodalLoader with ocr_images=True for scanned content.
  • Setting extract_images=True in PDFLoaderConfig and expecting image text to appear — this field is read nowhere in pdf_loader.py.
  • Not handling ValueError for password-protected PDFs separately from RuntimeError for missing backends — they represent very different failure modes.

Related pages