From ca1e5d92b5aa76b0aa1c8c66167f2d9bce710d92 Mon Sep 17 00:00:00 2001 From: n0nuser Date: Mon, 6 Jul 2026 21:58:56 +0200 Subject: [PATCH] feat: add OCR fallback for scanned PDF pages pypdf's text extraction returns empty text for image-only/scanned PDF pages, silently dropping their content during ingestion. Pages whose extracted text falls below OCR_MIN_CHARS_PER_PAGE are now rasterized with pypdfium2 and read with Tesseract via pytesseract; OCR failures (e.g. missing tesseract binary) fall back to the original text instead of raising. Adds OCR_ENABLED/OCR_LANGUAGE/OCR_MIN_CHARS_PER_PAGE settings, installs tesseract-ocr in the Docker image, and documents the feature in docs/ocr.md. --- .env.example | 6 ++ Dockerfile | 5 ++ docs/agent-navigation.md | 2 + docs/architecture.md | 3 +- docs/ocr.md | 25 +++++++ localrag/ingestion/parsers/pdf.py | 45 ++++++++++- localrag/settings.py | 11 +++ pyproject.toml | 3 +- tests/test_pdf_ocr.py | 119 ++++++++++++++++++++++++++++++ uv.lock | 46 ++++++++++++ 10 files changed, 259 insertions(+), 6 deletions(-) create mode 100644 docs/ocr.md create mode 100644 tests/test_pdf_ocr.py diff --git a/.env.example b/.env.example index 990812e..a3470db 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,12 @@ EMBEDDING_BATCH_SIZE=32 INGEST_RECURSIVE=true INGEST_ROOTS=[] +# OCR fallback for scanned/image-only PDF pages (requires the `tesseract` binary +# on the host; see docs/ocr.md). Set OCR_ENABLED=false to disable. +OCR_ENABLED=true +OCR_LANGUAGE=eng +OCR_MIN_CHARS_PER_PAGE=20 + RAG_TOP_K=5 RETRIEVAL_MODE=hybrid BM25_WEIGHT=0.5 diff --git a/Dockerfile b/Dockerfile index 2ef20e4..b7e6acd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,11 @@ FROM python:3.13-slim AS base WORKDIR /app +# tesseract-ocr: required at runtime for scanned/image-only PDF pages (see docs/ocr.md). +RUN apt-get update \ + && apt-get install --no-install-recommends -y tesseract-ocr \ + && rm -rf /var/lib/apt/lists/* + RUN pip install --no-cache-dir uv COPY pyproject.toml README.md /app/ diff --git a/docs/agent-navigation.md b/docs/agent-navigation.md index 910798d..da78211 100644 --- a/docs/agent-navigation.md +++ b/docs/agent-navigation.md @@ -40,6 +40,7 @@ Agents (and humans) move faster when they: | Architecture decisions | `docs/adr/` | | CLI commands | `localrag/cli/app.py`, `localrag/cli/commands/*.py` | | Parsing a file type | `localrag/ingestion/parsers/`, `localrag/ingestion/loader.py` | +| PDF OCR (scanned/image-only pages) | `localrag/ingestion/parsers/pdf.py`, `OCR_*` in `localrag/settings.py`, [ocr.md](ocr.md) | | Chunking strategy and boundaries | `localrag/ingestion/structural_chunker.py`, `localrag/ingestion/chunker.py`, `localrag/settings.py` | | Embeddings / Ollama HTTP for embed | `localrag/ingestion/embedder.py` | | Ingest orchestration | `localrag/ingestion/service.py` | @@ -48,6 +49,7 @@ Agents (and humans) move faster when they: | Ollama HTTP request/response shapes | `localrag/ollama/schemas.py` (used by embedder, RAG engine, health, setup) | | Prompt / answer streaming | `localrag/rag/prompt.py`, `localrag/rag/engine.py` | | Human Ollama install (not Python) | [ollama.md](ollama.md) | +| Human Tesseract install (not Python) | [ocr.md](ocr.md) | ## Commands (uv) diff --git a/docs/architecture.md b/docs/architecture.md index bdb7b01..718b85e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,7 @@ flowchart LR | HTTP API (persistence) | `localrag/api/repository.py` | `ChromaCollectionRepository` → `VectorStore` for collection list/delete and health’s collection list | | CLI | `localrag/cli/app.py`, `localrag/cli/commands/*` | `localrag` Typer entry (`pyproject` `[project.scripts]`) | | Ingestion orchestration | `localrag/ingestion/service.py` | `IngestionService`: paths → parse → chunk → embed → upsert | -| File formats | `localrag/ingestion/parsers/*` | pdf, docx, markdown, text, code | +| File formats | `localrag/ingestion/parsers/*` | pdf (with OCR fallback via pypdfium2 + pytesseract), docx, markdown, text, code | | Chunking / embed | `localrag/ingestion/structural_chunker.py`, `localrag/ingestion/chunker.py`, `localrag/ingestion/embedder.py` | Structural chunking by markdown/code/text boundaries with fixed fallback; Ollama **`POST /api/embed`** (see `localrag/ollama/schemas.py`) | | Storage | `localrag/storage/vector_store.py` | Chroma client wrapper | | RAG | `localrag/rag/retriever.py`, `bm25_index.py`, `engine.py`, `prompt.py` | Hybrid retrieval (vector + BM25), freshness decay reranking, prompt build, LLM call | @@ -104,6 +104,7 @@ The `reasoning` field records which path was taken. The router in `localrag/api/ ## Extension points - **New file type:** add a parser under `localrag/ingestion/parsers/`, register it via `loader` / parser dispatch (see `localrag/ingestion/loader.py`). +- **PDF OCR behavior:** edit `localrag/ingestion/parsers/pdf.py` and the `OCR_*` settings in `localrag/settings.py`; see [ocr.md](ocr.md) for the Tesseract install requirement. - **Chunking behavior:** edit `localrag/ingestion/structural_chunker.py` (or fixed fallback `localrag/ingestion/chunker.py`) and related knobs in `localrag/settings.py`. - **New HTTP surface:** add schemas in `localrag/api/schemas.py`, application logic in `localrag/api/service.py`, persistence in `localrag/api/repository.py` (if new storage access), thin router in `localrag/api/routers/`, wire DI in `localrag/api/dependencies.py`, include the router in `localrag/api/main.py`. - **New CLI command:** new module under `localrag/cli/commands/`, register in `localrag/cli/app.py`. diff --git a/docs/ocr.md b/docs/ocr.md new file mode 100644 index 0000000..97dfc3d --- /dev/null +++ b/docs/ocr.md @@ -0,0 +1,25 @@ +# PDF OCR + +LocalRAG falls back to **OCR** for scanned/image-only PDF pages during ingestion. `pypdf` extracts each page's text layer first; any page whose extracted text is shorter than `OCR_MIN_CHARS_PER_PAGE` is rasterized with **pypdfium2** and read with **Tesseract** via `pytesseract`. This is implemented in `localrag/ingestion/parsers/pdf.py`. + +Tesseract is a separate binary—not a Python package—so it must be installed on whatever host or container runs ingestion. + +## Settings + +| Env var | Default | Meaning | +| --- | --- | --- | +| `OCR_ENABLED` | `true` | Set to `false` to disable OCR entirely; scanned pages then yield empty text, as before this feature existed. | +| `OCR_LANGUAGE` | `eng` | Tesseract language code (`ollama`-style tag list: `tesseract --list-langs`). Install the matching `tesseract-ocr-` package for non-English text. | +| `OCR_MIN_CHARS_PER_PAGE` | `20` | Pages with a `pypdf` text layer shorter than this are treated as scanned and sent through OCR. | + +## Installing Tesseract + +- **Debian/Ubuntu (and this project's Docker image):** `apt-get install tesseract-ocr` (add `tesseract-ocr-` for extra languages, e.g. `tesseract-ocr-spa`). +- **macOS:** `brew install tesseract`. +- **Windows:** see the [Tesseract wiki install guide](https://github.com/tesseract-ocr/tesseract/blob/main/INSTALL.md). + +If `tesseract` is missing from `PATH`, OCR fails silently per page (logged as a warning) and ingestion keeps whatever text `pypdf` extracted—ingestion never fails because of a missing OCR binary. + +## Docker + +The provided `Dockerfile` installs `tesseract-ocr` (English only) in the base image. To OCR other languages inside Docker, add the relevant `tesseract-ocr-` package to the `apt-get install` line and set `OCR_LANGUAGE` accordingly. diff --git a/localrag/ingestion/parsers/pdf.py b/localrag/ingestion/parsers/pdf.py index 45d4e86..ca7a047 100644 --- a/localrag/ingestion/parsers/pdf.py +++ b/localrag/ingestion/parsers/pdf.py @@ -1,13 +1,50 @@ from __future__ import annotations +import logging from pathlib import Path -from pypdf import PdfReader +import pypdfium2 as pdfium +import pytesseract +from pypdf import PageObject, PdfReader + +from localrag.settings import Settings, get_settings + +logger = logging.getLogger(__name__) def parse_pdf(path: Path) -> str: + settings = get_settings() reader = PdfReader(str(path)) - parts: list[str] = [] - for page in reader.pages: - parts.append(page.extract_text() or "") + ocr_doc = pdfium.PdfDocument(str(path)) if settings.ocr_enabled else None + try: + parts = [ + _extract_page_text(page, index, ocr_doc, settings) + for index, page in enumerate(reader.pages) + ] + finally: + if ocr_doc is not None: + ocr_doc.close() return "\n".join(parts).strip() + + +def _extract_page_text( + page: PageObject, + index: int, + ocr_doc: pdfium.PdfDocument | None, + settings: Settings, +) -> str: + text = (page.extract_text() or "").strip() + if ocr_doc is None or len(text) >= settings.ocr_min_chars_per_page: + return text + ocr_text = _ocr_page(ocr_doc, index, settings.ocr_language) + return ocr_text or text + + +def _ocr_page(ocr_doc: pdfium.PdfDocument, index: int, language: str) -> str: + try: + bitmap = ocr_doc[index].render(scale=2.0) + image = bitmap.to_pil() + return pytesseract.image_to_string(image, lang=language).strip() + except Exception: + logger.warning("ocr_page_failed page=%d", index, exc_info=True) + return "" diff --git a/localrag/settings.py b/localrag/settings.py index 7db9b97..26db4c4 100644 --- a/localrag/settings.py +++ b/localrag/settings.py @@ -34,6 +34,13 @@ class Settings(BaseSettings): non-empty, only files and directories under those paths (after resolving) are allowed through the HTTP ingest API; an empty list disables that restriction. + **PDF OCR** — When ``ocr_enabled`` is true (default), PDF pages whose extracted + text layer is shorter than ``ocr_min_chars_per_page`` (scanned/image-only pages) + are rasterized and run through Tesseract OCR (``ocr_language`` is a Tesseract + language code, e.g. ``eng``). Requires the ``tesseract`` binary on the host; if + it is missing, OCR fails silently per-page and the original (possibly empty) + text-layer output is kept. See `docs/ocr.md`. + **RAG** — ``rag_top_k`` is how many chunks are retrieved for context. ``rag_system_prompt`` is the system message for the answering model. @@ -68,6 +75,10 @@ class Settings(BaseSettings): ingest_recursive: bool = True ingest_roots: list[str] = [] + ocr_enabled: bool = True + ocr_language: str = "eng" + ocr_min_chars_per_page: int = 20 + rag_top_k: int = 5 retrieval_mode: str = "hybrid" bm25_weight: float = 0.5 diff --git a/pyproject.toml b/pyproject.toml index 067304c..31416a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ dependencies = [ "chromadb>=1.0", "httpx>=0.28", "pypdf>=5.4", + "pypdfium2>=4.30", + "pytesseract>=0.3.13", "python-docx>=1.1", "pydantic-settings>=2.8", "rich>=13", @@ -114,7 +116,6 @@ source = ["localrag"] omit = [ "localrag/cli/*", "localrag/cli/**", - "localrag/ingestion/parsers/pdf.py", ] [tool.coverage.report] diff --git a/tests/test_pdf_ocr.py b/tests/test_pdf_ocr.py new file mode 100644 index 0000000..b0f8801 --- /dev/null +++ b/tests/test_pdf_ocr.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from localrag.ingestion.parsers import pdf as pdf_module +from localrag.settings import Settings + + +class _FakePage: + def __init__(self, text: str) -> None: + self._text = text + + def extract_text(self) -> str: + return self._text + + +class _FakeReader: + def __init__(self, pages: list[_FakePage]) -> None: + self.pages = pages + + +class _FakeBitmap: + def to_pil(self) -> object: + return object() + + +class _FakePdfPage: + def render(self, scale: float = 2.0) -> _FakeBitmap: + return _FakeBitmap() + + +class _FakePdfDocument: + def __init__(self, page_count: int) -> None: + self._pages = [_FakePdfPage() for _ in range(page_count)] + self.closed = False + + def __getitem__(self, index: int) -> _FakePdfPage: + return self._pages[index] + + def close(self) -> None: + self.closed = True + + +def _settings(**overrides: Any) -> Settings: + return Settings(**overrides) + + +def test_parse_pdf_uses_text_layer_when_long_enough( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + path = tmp_path / "a.pdf" + path.write_bytes(b"%PDF-1.4\n") + + monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("x" * 50)])) + monkeypatch.setattr(pdf_module, "get_settings", lambda: _settings(ocr_min_chars_per_page=20)) + monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", lambda _: _FakePdfDocument(1)) + + def _fail_ocr(*_args: Any, **_kwargs: Any) -> str: + raise AssertionError("OCR should not run when the text layer is long enough") + + monkeypatch.setattr(pdf_module.pytesseract, "image_to_string", _fail_ocr) + + assert pdf_module.parse_pdf(path) == "x" * 50 + + +def test_parse_pdf_falls_back_to_ocr_when_text_layer_too_short( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + path = tmp_path / "a.pdf" + path.write_bytes(b"%PDF-1.4\n") + + fake_doc = _FakePdfDocument(1) + monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("")])) + monkeypatch.setattr( + pdf_module, "get_settings", lambda: _settings(ocr_min_chars_per_page=20, ocr_language="eng") + ) + monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", lambda _: fake_doc) + monkeypatch.setattr( + pdf_module.pytesseract, "image_to_string", lambda _image, lang: f"OCR:{lang}" + ) + + assert pdf_module.parse_pdf(path) == "OCR:eng" + assert fake_doc.closed + + +def test_parse_pdf_skips_ocr_when_disabled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + path = tmp_path / "a.pdf" + path.write_bytes(b"%PDF-1.4\n") + + monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("")])) + monkeypatch.setattr(pdf_module, "get_settings", lambda: _settings(ocr_enabled=False)) + + def _fail_open(*_args: Any, **_kwargs: Any) -> _FakePdfDocument: + raise AssertionError("PdfDocument should not be opened when OCR is disabled") + + monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", _fail_open) + + assert pdf_module.parse_pdf(path) == "" + + +def test_parse_pdf_ocr_failure_falls_back_to_text_layer( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + path = tmp_path / "a.pdf" + path.write_bytes(b"%PDF-1.4\n") + + monkeypatch.setattr(pdf_module, "PdfReader", lambda _: _FakeReader([_FakePage("short")])) + monkeypatch.setattr(pdf_module, "get_settings", lambda: _settings(ocr_min_chars_per_page=20)) + monkeypatch.setattr(pdf_module.pdfium, "PdfDocument", lambda _: _FakePdfDocument(1)) + + def _raise(*_args: Any, **_kwargs: Any) -> str: + raise RuntimeError("tesseract not found") + + monkeypatch.setattr(pdf_module.pytesseract, "image_to_string", _raise) + + assert pdf_module.parse_pdf(path) == "short" diff --git a/uv.lock b/uv.lock index 2155192..78db2f5 100644 --- a/uv.lock +++ b/uv.lock @@ -1373,6 +1373,8 @@ dependencies = [ { name = "prometheus-client" }, { name = "pydantic-settings" }, { name = "pypdf" }, + { name = "pypdfium2" }, + { name = "pytesseract" }, { name = "python-docx" }, { name = "ragas" }, { name = "rank-bm25" }, @@ -1406,6 +1408,8 @@ requires-dist = [ { name = "prometheus-client", specifier = ">=0.25.0" }, { name = "pydantic-settings", specifier = ">=2.8" }, { name = "pypdf", specifier = ">=5.4" }, + { name = "pypdfium2", specifier = ">=4.30" }, + { name = "pytesseract", specifier = ">=0.3.13" }, { name = "python-docx", specifier = ">=1.1" }, { name = "ragas", specifier = ">=0.4.3" }, { name = "rank-bm25", specifier = ">=0.2.2" }, @@ -2563,6 +2567,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/fa/3597fb3fb28f40bf8291fdddbc4dcd51ce52fccaf1cbfca10ee9db09c69a/pypdf-6.12.0-py3-none-any.whl", hash = "sha256:a8e104ab950e655d0bcf5fa5e71317c06474bc707987335da44a210f73a8883b", size = 343457, upload-time = "2026-05-21T09:21:40.852Z" }, ] +[[package]] +name = "pypdfium2" +version = "5.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/b9/a887db9950379fe619f6921ade84730346e6adf008b197951ddcc42dfd1a/pypdfium2-5.11.0.tar.gz", hash = "sha256:0733749ac253c615953a5e75d4322b9f1de8c0d90137ec8dbcef403f3e57b5d9", size = 276614, upload-time = "2026-06-29T11:11:40.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/09/0aec4588adaa7eb4e42da127a57a5dd3a3997ff32be62af93f8afeb4668b/pypdfium2-5.11.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:115bc68ab2e85c9ee46f97a4c1794f1d7205c8b763fc5c16d9893eaa54608627", size = 3388299, upload-time = "2026-06-29T11:11:00.183Z" }, + { url = "https://files.pythonhosted.org/packages/7a/53/9e349a311cb0e7cef5dd0824a6f96c9b161da5e5e67a3db030d38e624fe9/pypdfium2-5.11.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:4396d0848b79134fcdf141a4e3dfe6719efe1162309a02876b440025cfd80720", size = 2843597, upload-time = "2026-06-29T11:11:02.351Z" }, + { url = "https://files.pythonhosted.org/packages/d2/13/13571dc7f1d11a4e4bde6ab8961318b04ff70bdc2aea5e7f88be5ef53167/pypdfium2-5.11.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:73fe55dd258f02332bc0a34128ddc2994fd610e664d1f2f7d78dd9e2570f15ee", size = 3586638, upload-time = "2026-06-29T11:11:04.264Z" }, + { url = "https://files.pythonhosted.org/packages/84/a2/9865e2822e97f6e4bed3b0e4838bd88444c62df6ebe0ccae352027133120/pypdfium2-5.11.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:badb4f4df98e25fb1b58301911c98fa18da60de98b0223a6ffa91717be068b96", size = 3649576, upload-time = "2026-06-29T11:11:06.011Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bb/dcd0ba152626c30a67bff43974b728c6bdd9a0d34af54cfa875489e46ce2/pypdfium2-5.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fbff7c3e3141f24f3a25cedddbb6c6d17baa95f83c7b7ea40d6866956df3a43", size = 3648292, upload-time = "2026-06-29T11:11:07.767Z" }, + { url = "https://files.pythonhosted.org/packages/65/62/39b40afda909bd65b212e9dbf32e7c6a2da4205ff64576965a49ba689c47/pypdfium2-5.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:faeda49c171be254b5028b7d17e54306c585374210d3e7ea4962a36774d5b76a", size = 3380060, upload-time = "2026-06-29T11:11:09.571Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cf/999b793ca17d153765dc3656e7f4cc90c4bf411b9262f15b8e7e31af164f/pypdfium2-5.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b63f6f1e67d05d2bc36f55c35ede5cd18ded8886a2fcf68f320d188ee7934348", size = 3778180, upload-time = "2026-06-29T11:11:11.234Z" }, + { url = "https://files.pythonhosted.org/packages/73/6c/54fd2b487eed6a420ee7fff5c7754739379c4459fa185deab3cb2b72e4e2/pypdfium2-5.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44f96d8459b4ba9d1330ab44a8dde836b361cb00027830eaab710080888cf44a", size = 4190704, upload-time = "2026-06-29T11:11:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/690b9a1b8de405ff7693c1d10472f0c5395294c6d53654ae1ef97ccf70fa/pypdfium2-5.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba8a78329c10f01c80b1f6e9aa33cac13665c198d056a007827b703958bd986d", size = 3705232, upload-time = "2026-06-29T11:11:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/54/fc/18298367dc073041320bbb5d51fb3b42b0d9164fd13bd4d13dcb0d66bc64/pypdfium2-5.11.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7a60197c5d18b0ec77a695e4a1082f89f323c13063cecf4446f1297ed632a36", size = 4031064, upload-time = "2026-06-29T11:11:17.201Z" }, + { url = "https://files.pythonhosted.org/packages/45/1f/53d5b6ab0869963474b7ea13097c5abd8f2cf13548f0076e9bafc7e9d6b0/pypdfium2-5.11.0-py3-none-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d167456433d565398a5c9f1efad23dfa6dfadc1a201ec687594fec645d843e", size = 3995092, upload-time = "2026-06-29T11:11:18.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/7a/942a390e41fb59a797d9303eae6e6a8c3d1b84deade03206ac163076033f/pypdfium2-5.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:953f5bcb6a65067381f0a38c50ffaed864fac6f20b0fa2c14ffb15a52bda8943", size = 4995668, upload-time = "2026-06-29T11:11:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e9/fd1f8d3157c92665166f7d9bfcdd26901290b4271fcd2f90c7419ca0ee4c/pypdfium2-5.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:479b081423b2c16de44ddb6e25826d665298e1ee7cf09d802b656716dd930201", size = 4539453, upload-time = "2026-06-29T11:11:22.474Z" }, + { url = "https://files.pythonhosted.org/packages/e9/56/4280069868245d2346e6ec7b42e155c16a19be731f2f57fb726c35661aac/pypdfium2-5.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:81356f0503356d6f21629fcd47bbfffcfe26487f47a7989a56c02bd4e39b4c55", size = 5237429, upload-time = "2026-06-29T11:11:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/84/9f/99d962bea28c3305835d07a1732a771104ce958f46e68a1892e740a7c904/pypdfium2-5.11.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:25be9d70f5776c68fa28b92ec42a7be91de5d6c4085ed5c6c89e1bfa373ae918", size = 5143685, upload-time = "2026-06-29T11:11:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4a/c935d1fdd73092fd036627282948c794c8f14a7ddde39cf732898b258866/pypdfium2-5.11.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:8edec20239f77d4dea2eeee5d29ebd83e77e1e2900691b77387fead76f81bc4a", size = 4647708, upload-time = "2026-06-29T11:11:28.006Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/f4f1895efa581f8b85748bf8d620ad37550583ca5226feeb9a33dcb3031e/pypdfium2-5.11.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:2560db37ce77a6caf651dc129bc44ee610a5fbb0050ec70f5a643f76bf241173", size = 5089405, upload-time = "2026-06-29T11:11:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/ca5b1071aee0a96937a30007504a0ca484340709e22a459e99650165fd57/pypdfium2-5.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34086bc1fcb964bb8104889d941ba9c8a35a75866250bb81d2f2bb2791bb42af", size = 5051368, upload-time = "2026-06-29T11:11:31.868Z" }, + { url = "https://files.pythonhosted.org/packages/28/01/e445a77e784a5b0047a2c30b263630dfaa57318ff3196e6ad3093dca6929/pypdfium2-5.11.0-py3-none-win32.whl", hash = "sha256:324054f36acf6bea42a9d2534eb407da2e312dba746d955c9fd7e324bc160898", size = 3645281, upload-time = "2026-06-29T11:11:33.691Z" }, + { url = "https://files.pythonhosted.org/packages/73/d4/8b8af6eedbc5c8af49817d8f37c2e55be8a2c7f75fca9bee78efbfeb40f6/pypdfium2-5.11.0-py3-none-win_amd64.whl", hash = "sha256:d3b698e7b51bdf2d633cc834395677319e1d66757896b30c632dfac7d7236c81", size = 3778234, upload-time = "2026-06-29T11:11:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/657503553694c70fba0aa5d213144d80401611c82e0205f43f1ff39f40af/pypdfium2-5.11.0-py3-none-win_arm64.whl", hash = "sha256:897788d71b740752e29875856c369fdb52283f609aecf2355f0473db05dfc1b6", size = 3567726, upload-time = "2026-06-29T11:11:38.689Z" }, +] + [[package]] name = "pypika" version = "0.51.1" @@ -2581,6 +2614,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] +[[package]] +name = "pytesseract" +version = "0.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a6/7d679b83c285974a7cb94d739b461fa7e7a9b17a3abfd7bf6cbc5c2394b0/pytesseract-0.3.13.tar.gz", hash = "sha256:4bf5f880c99406f52a3cfc2633e42d9dc67615e69d8a509d74867d3baddb5db9", size = 17689, upload-time = "2024-08-16T02:33:56.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/33/8312d7ce74670c9d39a532b2c246a853861120486be9443eebf048043637/pytesseract-0.3.13-py3-none-any.whl", hash = "sha256:7a99c6c2ac598360693d83a416e36e0b33a67638bb9d77fdcac094a3589d4b34", size = 14705, upload-time = "2024-08-16T02:36:10.09Z" }, +] + [[package]] name = "pytest" version = "9.0.3"