Source code for aeat.adapters.inbound.pdf._pdfplumber

"""Shared pdfplumber-backed page-text extraction.

Backs the per-format ``extract_pages_text`` / ``extract_pages_text_from_bytes``
public surfaces (borrador, declaracion, justificante). Each per-format
backend remains the public entry point and is responsible for binding
its own format identity to the primitive: it injects the format-specific
error class plus the diagnostic phrasing that error messages must carry.

Public surface
==============

Three return-shape families are supported:

* :func:`extract_pages_text_from_path` /
  :func:`extract_pages_text_from_bytes` — tuple-of-stripped-strings,
  empty-PDF guard raised as ``error_class``. Used by borrador and
  declaracion.
* :func:`extract_pages_text_concatenated` — single ``str`` joined from
  the non-empty pages, no empty-PDF guard. Used by justificante where
  layout-sensitive parsing happens downstream.
* :func:`extract_pages_text_with_fast_path` — wraps the path-based
  primitive with an optional caller-supplied fast-path extractor (e.g.
  the pypdfium2 path used by declaracion). The fast-path runs first; if
  it returns ``None`` the function falls back to pdfplumber.

All path-based diagnostics use the redacted ``<input-pdf>`` label. The bytes
entry point is for secure-storage flows that should never materialise source
PDF bytes as a plaintext temporary file.

The ``pdfminer`` logger level is governed centrally by
``aeat.core.logging.configure_logging()`` dictConfig (``WARNING``).
"""

from __future__ import annotations

from collections.abc import Callable
from io import BytesIO
from pathlib import Path

import pdfplumber

_INPUT_PDF_SOURCE_LABEL = "<input-pdf>"


def _pdfplumber_failure_label(exc: Exception) -> str:
    return type(exc).__name__


[docs] def extract_pages_text_from_path( pdf_path: Path, *, error_class: type[Exception], not_found_label: str, pdf_label: str, ) -> tuple[str, ...]: """Read ``pdf_path`` page-by-page and return one stripped string per page. Args: pdf_path: Filesystem path of the PDF to read. error_class: Format-specific exception class to raise on every failure mode (missing file, pdfplumber failure, empty PDF). Each per-format backend injects its own (e.g. ``BorradorParseError``, ``DeclaracionParseError``) so callers can ``except`` by their familiar concrete type. not_found_label: Prefix the file-not-found message uses (e.g. ``"Modelo 100 PDF not found"``, ``"declaración PDF not found"``). pdf_label: Article-prefixed phrase the empty-PDF message uses (e.g. ``"PDF"``, ``"the PDF"``) so the diagnostic reads naturally per format. Returns: Tuple of stripped per-page text in page order. Empty pages preserve their slot as the empty string. Raises: Exception: An instance of the supplied ``error_class`` when the file does not exist, when pdfplumber cannot open it, or when every page is empty (suggesting a scan-only / XFA PDF without an embedded text layer). """ if not pdf_path.is_file(): raise error_class(f"{not_found_label}: {_INPUT_PDF_SOURCE_LABEL}") try: with pdfplumber.open(pdf_path) as pdf: pages = tuple((page.extract_text() or "").strip() for page in pdf.pages) except Exception as exc: failure = _pdfplumber_failure_label(exc) raise error_class(f"pdfplumber could not open {_INPUT_PDF_SOURCE_LABEL}: {failure}") from exc if not any(pages): raise error_class(f"no text extracted from {_INPUT_PDF_SOURCE_LABEL}; {pdf_label} may be scan-only or XFA") return pages
[docs] def extract_pages_text_from_bytes( pdf_bytes: bytes, *, error_class: type[Exception], pdf_label: str, source_label: str = "in-memory PDF", ) -> tuple[str, ...]: """Extract text from PDF bytes without materialising a plaintext file. Args: pdf_bytes: In-memory source PDF bytes, typically read from secure storage. error_class: Format-specific exception class raised on pdfplumber failure or an all-empty text layer. pdf_label: Article-prefixed phrase for the empty-PDF diagnostic. source_label: Redacted source phrase used in errors. Defaults to ``"in-memory PDF"``. Returns: Tuple of stripped per-page text in page order. """ try: with pdfplumber.open(BytesIO(pdf_bytes)) as pdf: pages = tuple((page.extract_text() or "").strip() for page in pdf.pages) except Exception as exc: # pragma: no cover — defensive; pdfplumber surface raise error_class(f"pdfplumber could not open {source_label}: {exc}") from exc if not any(pages): raise error_class(f"no text extracted from {source_label}; {pdf_label} may be scan-only or XFA") return pages
[docs] def extract_pages_text_concatenated( pdf_path: Path, *, error_class: type[Exception], ) -> str: """Return the concatenated text of ``pdf_path`` using pdfplumber. Skips empty pages and joins the remaining ``page.extract_text()`` output with newlines. Unlike :func:`extract_pages_text_from_path`, this helper does NOT raise on an all-empty PDF; downstream layout-sensitive parsers (justificante) decide what an empty document means in their own domain terms. Args: pdf_path: Filesystem path of the PDF to read. error_class: Format-specific exception raised when pdfplumber cannot open the file. Returns: Single string with every non-empty page's text joined by newlines. May be empty if every page is empty. Raises: Exception: An instance of the supplied ``error_class`` when pdfplumber cannot open the file. """ try: with pdfplumber.open(str(pdf_path)) as pdf: chunks: list[str] = [] for page in pdf.pages: text = page.extract_text() if text: chunks.append(text) return "\n".join(chunks) except error_class: raise except Exception as exc: failure = _pdfplumber_failure_label(exc) raise error_class(f"pdfplumber failed to open {_INPUT_PDF_SOURCE_LABEL}: {failure}") from exc
[docs] def extract_pages_text_with_fast_path( pdf_path: Path, *, error_class: type[Exception], not_found_label: str, pdf_label: str, fast_path_extractor: Callable[[Path], tuple[str, ...] | None] | None = None, ) -> tuple[str, ...]: """Try ``fast_path_extractor`` first, falling back to pdfplumber. The fast-path callable receives the same ``pdf_path`` and must return either a tuple of stripped per-page strings (consumed verbatim) or ``None`` to signal "fall through to pdfplumber". Format-specific extractors (e.g. the pypdfium2 path used by declaracion with declaration-content canary regexes) live in the calling module so the canonical primitive stays content-neutral. When ``fast_path_extractor`` is ``None`` this function is equivalent to :func:`extract_pages_text_from_path`. """ if fast_path_extractor is not None and pdf_path.is_file(): fast_pages = fast_path_extractor(pdf_path) if fast_pages is not None: return fast_pages return extract_pages_text_from_path( pdf_path, error_class=error_class, not_found_label=not_found_label, pdf_label=pdf_label, )
__all__ = [ "extract_pages_text_concatenated", "extract_pages_text_from_bytes", "extract_pages_text_from_path", "extract_pages_text_with_fast_path", ]