Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ FROM python:3.12-slim-bookworm

WORKDIR /app

# Install runtime dependencies (tesseract and languages)
# Install runtime dependencies (tesseract, languages, and poppler)
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-por \
tesseract-ocr-eng \
poppler-utils \
&& rm -rf /var/lib/apt/lists/*

# Copy virtual environment from builder
Expand Down
52 changes: 46 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
## 🇺🇸 English

### Overview
This project provides a robust API for Optical Character Recognition (OCR) capable of extracting text from images. It supports multiple languages (English and Portuguese), mixed-language documents, and offers configurable processing modes for speed or accuracy.
This project provides a robust API for Optical Character Recognition (OCR) capable of extracting text from images and PDF documents. It supports multiple languages (English and Portuguese), mixed-language documents, and offers configurable processing modes for speed or accuracy.

### Features
- **Multi-language Support**: English (`eng`), Portuguese (`por`), and mixed (`eng+por`).
Expand All @@ -20,6 +20,7 @@ This project provides a robust API for Optical Character Recognition (OCR) capab
- `accurate`: Enhanced preprocessing (rescaling, contrast adjustment) for better results on difficult images.
- **Auto-Detection**: Orientation and Script Detection (OSD) to handle rotated images or unknown scripts.
- **Strict Validation**: Validates file types via magic bytes (MIME type verification) to ensure security.
- **PDF Support**: Extract text from PDF documents (hybrid approach: converts pages to images for robust OCR).
- **Dockerized**: Optimized multi-stage Docker build for easy deployment.

### Installation & Running
Expand All @@ -35,7 +36,7 @@ docker run -p 8080:8080 api_ocr
The API will be available at `http://localhost:8080`.

#### Local Development
Prerequisites: Python 3.12+, `uv` (dependency manager), and Tesseract OCR installed on your system.
Prerequisites: Python 3.12+, `uv` (dependency manager), `poppler-utils` (for PDF processing), and Tesseract OCR installed on your system.

```bash
# Install dependencies
Expand All @@ -47,7 +48,7 @@ uv run uvicorn app.main:app --reload --port 8080

### API Usage

#### Extract Text
#### 1. Extract Text from Images
**Endpoint**: `POST /extract_text`

**Parameters (Form Data):**
Expand All @@ -67,12 +68,31 @@ curl -X 'POST' \
-F 'mode=accurate'
```

#### 2. Extract Text from PDF
**Endpoint**: `POST /extract_pdf`

**Parameters (Form Data):**
- `input_file`: Single PDF file.
- `lang`, `mode`, `auto_detect`: Same as image endpoint.
- `force_processing`: Boolean (`true`/`false`). By default, PDFs with > 10 pages are rejected to save resources. Set this to `true` to override the limit.

**Example (cURL):**
```bash
curl -X 'POST' \
'http://localhost:8080/extract_pdf' \
-H 'accept: application/json' \
-H 'Content-Type: multipart/form-data' \
-F 'input_file=@document.pdf;type=application/pdf' \
-F 'force_processing=true'
```

### Development Guide

#### Architecture
- **FastAPI**: Handles HTTP requests and validation.
- **Tesseract OCR**: The core OCR engine.
- **Pillow (PIL)**: Used for image preprocessing in `accurate` mode.
- **pdf2image / Poppler**: Converts PDF pages to images for reliable OCR.
- **Filetype**: Validates image magic bytes securely.
- **AsyncIO**: OCR tasks run in thread pools to prevent blocking the event loop.

Expand All @@ -87,7 +107,7 @@ uv run pytest
## 🇧🇷 Português

### Visão Geral
Este projeto fornece uma API robusta para Reconhecimento Óptico de Caracteres (OCR) capaz de extrair texto de imagens. Suporta múltiplos idiomas (Inglês e Português), documentos com idiomas mistos e oferece modos de processamento configuráveis para velocidade ou precisão.
Este projeto fornece uma API robusta para Reconhecimento Óptico de Caracteres (OCR) capaz de extrair texto de imagens e documentos PDF. Suporta múltiplos idiomas (Inglês e Português), documentos com idiomas mistos e oferece modos de processamento configuráveis para velocidade ou precisão.

### Funcionalidades
- **Suporte Multi-idioma**: Inglês (`eng`), Português (`por`) e misto (`eng+por`).
Expand All @@ -96,6 +116,7 @@ Este projeto fornece uma API robusta para Reconhecimento Óptico de Caracteres (
- `accurate` (Preciso): Pré-processamento aprimorado (redimensionamento, ajuste de contraste) para melhores resultados em imagens difíceis.
- **Detecção Automática**: Detecção de Orientação e Script (OSD) para lidar com imagens rotacionadas ou scripts desconhecidos.
- **Validação Rigorosa**: Valida tipos de arquivos via *magic bytes* (verificação de tipo MIME) para garantir segurança.
- **Suporte a PDF**: Extrai texto de documentos PDF (abordagem híbrida: converte páginas em imagens para OCR robusto).
- **Dockerizado**: Build Docker multi-estágio otimizado para fácil implantação.

### Instalação e Execução
Expand All @@ -111,7 +132,7 @@ docker run -p 8080:8080 api_ocr
A API estará disponível em `http://localhost:8080`.

#### Desenvolvimento Local
Pré-requisitos: Python 3.12+, `uv` (gerenciador de dependências) e Tesseract OCR instalado no sistema.
Pré-requisitos: Python 3.12+, `uv` (gerenciador de dependências), `poppler-utils` (para processamento de PDF) e Tesseract OCR instalado no sistema.

```bash
# Instalar dependências
Expand All @@ -123,7 +144,7 @@ uv run uvicorn app.main:app --reload --port 8080

### Uso da API

#### Extrair Texto
#### 1. Extrair Texto de Imagens
**Endpoint**: `POST /extract_text`

**Parâmetros (Form Data):**
Expand All @@ -143,12 +164,31 @@ curl -X 'POST' \
-F 'mode=accurate'
```

#### 2. Extrair Texto de PDF
**Endpoint**: `POST /extract_pdf`

**Parâmetros (Form Data):**
- `input_file`: Arquivo PDF único.
- `lang`, `mode`, `auto_detect`: Iguais ao endpoint de imagem.
- `force_processing`: Booleano (`true`/`false`). Por padrão, PDFs com mais de 10 páginas são rejeitados para economizar recursos. Defina como `true` para ignorar o limite.

**Exemplo (cURL):**
```bash
curl -X 'POST' \
'http://localhost:8080/extract_pdf' \
-H 'accept: application/json' \
-H 'Content-Type: multipart/form-data' \
-F 'input_file=@documento.pdf;type=application/pdf' \
-F 'force_processing=true'
```

### Guia de Desenvolvimento

#### Arquitetura
- **FastAPI**: Gerencia requisições HTTP e validação.
- **Tesseract OCR**: O motor principal de OCR.
- **Pillow (PIL)**: Usado para pré-processamento de imagens no modo `accurate`.
- **pdf2image / Poppler**: Converte páginas de PDF em imagens para OCR confiável.
- **Filetype**: Valida *magic bytes* de imagens de forma segura.
- **AsyncIO**: Tarefas de OCR rodam em *thread pools* para não bloquear o *event loop*.

Expand Down
67 changes: 67 additions & 0 deletions app/api/pdf_extract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from typing import List, Optional
import time
import os

from app.domain import pdf_ocr, fileUpload
from app.model.TextSchema import TextExtractDocument

router = APIRouter()

@router.post("/extract_pdf", response_model=List[TextExtractDocument])
async def extract_pdf(
input_file: UploadFile = File(..., description="PDF file to process"),
lang: str = Form("eng+por", description="Language code (eng, por, eng+por). Use 'auto' to enable OSD."),
mode: str = Form("fast", description="OCR mode: 'fast' or 'accurate'."),
auto_detect: bool = Form(False, description="Enable Orientation and Script Detection (OSD)."),
force_processing: bool = Form(False, description="Force processing even if page count > 10.")
):
"""
Extract text from uploaded PDF document.

- **input_file**: PDF file to process.
- **lang**: Language(s) to use for OCR. Defaults to 'eng+por'.
- **mode**: processing mode. 'fast' is quicker, 'accurate' performs preprocessing.
- **auto_detect**: Explicitly enable OSD (Orientation and Script Detection).
- **force_processing**: Set to True to bypass the 10-page limit safeguard.
"""

# Validate mode
if mode not in ["fast", "accurate"]:
raise HTTPException(status_code=400, detail="Invalid mode. Use 'fast' or 'accurate'.")

# Handle 'auto' lang
if lang == "auto":
auto_detect = True
lang = "eng+por"

start_time = time.time()
print(f"Processing PDF: {input_file.filename}")

# Validate file type
fileUpload.validate_pdf_file(input_file)

# Save file using tempfile
temp_file = fileUpload._save_file_to_server(input_file)

try:
# Process PDF
text = await pdf_ocr.process_pdf(
file_path=temp_file,
lang=lang,
mode=mode,
auto=auto_detect,
force_processing=force_processing
)
Comment on lines +47 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Large PDFs with force_processing=True will load all page images into memory at once.

Looking at app/domain/pdf_ocr.py (lines 48-51 in the relevant snippet), convert_from_path materializes every page as a PIL Image simultaneously. For a 100-page PDF at 300 DPI, this can easily consume multiple GB of RAM and risk an OOM kill.

Consider converting pages in batches (pdf2image supports first_page/last_page parameters) or streaming one page at a time to bound memory usage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/pdf_extract.py` around lines 47 - 55, process_pdf currently lets
pdf2image.convert_from_path materialize all pages at once when
force_processing=True, which OOMs for large PDFs; change process_pdf in
app/domain/pdf_ocr.py to load and process pages in bounded batches or
one-at-a-time using pdf2image's first_page/last_page (or equivalent single-page
conversion) rather than converting the whole PDF at once, and wire that behavior
to the existing force_processing flag so large documents are processed
incrementally (iterate page ranges, call convert_from_path per batch, and free
images after OCR) to cap memory usage.

finally:
if os.path.exists(temp_file):
os.remove(temp_file)

time_taken = str(round((time.time() - start_time), 2))

# Return list with single document result (consistent with image endpoint structure)
return [TextExtractDocument(
file_name=input_file.filename or "unknown",
text=text,
time_taken=time_taken
)]
37 changes: 37 additions & 0 deletions app/domain/fileUpload.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,40 @@ def validate_image_file(file: UploadFile):
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=400, detail=f"Error validating file: {str(e)}")

def validate_pdf_file(file: UploadFile):
"""
Validates if the uploaded file is a valid PDF using magic bytes.
Raises HTTPException(400) if invalid.
"""
try:
# Read the first 2048 bytes to detect file type
header = file.file.read(2048)
file.file.seek(0) # Reset file pointer

if not header:
raise HTTPException(status_code=400, detail="Empty file uploaded.")

kind = filetype.guess(header)

# PDF magic bytes might not always be detected by filetype if it's not strictly following standard or is a fragment,
# but filetype lib is generally good.
# However, specifically check for PDF mime type.

if kind is None:
# Fallback check for %PDF-
if header.startswith(b"%PDF-"):
return True
raise HTTPException(status_code=400, detail="Could not determine file type. Please upload a valid PDF.")

if kind.mime != "application/pdf":
# Fallback check for %PDF-
if header.startswith(b"%PDF-"):
return True
raise HTTPException(status_code=400, detail=f"Invalid file type: {kind.mime}. Only PDF files are allowed.")

return True
except Exception as e:
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=400, detail=f"Error validating file: {str(e)}")
73 changes: 73 additions & 0 deletions app/domain/pdf_ocr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import asyncio
import tempfile
import os
from pdf2image import convert_from_path, pdfinfo_from_path
from app.domain import ocr
from fastapi import HTTPException

async def process_pdf(
file_path: str,
lang: str = 'eng+por',
mode: str = 'fast',
auto: bool = False,
force_processing: bool = False
) -> str:
"""
Converts PDF to images and extracts text from each page.

Args:
file_path (str): Path to the PDF file.
lang (str): Language code.
mode (str): 'fast' or 'accurate'.
auto (bool): Enable OSD.
force_processing (bool): If True, ignores the 10-page limit.

Returns:
str: Concatenated text from all processed pages.
"""
try:
# Check page count
# pdfinfo_from_path runs 'pdfinfo' command which is part of poppler-utils
info = await asyncio.to_thread(pdfinfo_from_path, file_path)
page_count = info.get("Pages", 0)

if page_count > 10 and not force_processing:
raise HTTPException(
status_code=400,
detail=f"PDF has {page_count} pages. Limit is 10. Set 'force_processing' to True to override."
)

# Determine DPI based on mode
dpi = 300 if mode == 'accurate' else 200

# Convert PDF to images
# We run this in a thread because it's CPU bound
def convert():
return convert_from_path(file_path, dpi=dpi)

images = await asyncio.to_thread(convert)
Comment on lines +43 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

All PDF pages are loaded into memory at once — high memory risk.

convert_from_path materializes every page as a PIL Image simultaneously. For large PDFs (enabled via force_processing=True), this can consume several GB of RAM and potentially OOM the process. Consider processing pages in batches or one at a time using the first_page/last_page parameters of convert_from_path.

Sketch: page-by-page conversion
-        def convert():
-            return convert_from_path(file_path, dpi=dpi)
-
-        images = await asyncio.to_thread(convert)
-
         extracted_text = []
 
-        for i, image in enumerate(images):
+        for i in range(page_count):
+            page_num = i + 1
+            def convert_page(p=page_num):
+                return convert_from_path(file_path, dpi=dpi, first_page=p, last_page=p)
+
+            pages = await asyncio.to_thread(convert_page)
+            image = pages[0]
+
             with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_img:
                 image.save(temp_img.name)
                 temp_img_path = temp_img.name
 
             try:
                 page_text = await ocr.read_image(temp_img_path, lang=lang, mode=mode, auto=auto)
-                extracted_text.append(f"--- Page {i+1} ---\n{page_text}")
+                extracted_text.append(f"--- Page {page_num} ---\n{page_text}")
             finally:
+                image.close()
                 if os.path.exists(temp_img_path):
                     os.remove(temp_img_path)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/domain/pdf_ocr.py` around lines 43 - 48, The current convert() wrapper
calls convert_from_path which loads all pages into memory (images) at once;
change the logic in pdf_ocr.py to iterate over pages (or process in small
batches) by calling convert_from_path with first_page and last_page parameters
and handling each returned page before requesting the next batch, e.g., replace
the single convert()/images await with a loop that uses asyncio.to_thread to
convert one page or a batch at a time, process/append the results (or stream
them) and free references immediately to avoid keeping the entire PDF in memory;
update references to images and convert() accordingly so downstream code
consumes per-page outputs rather than assuming a full-image list.


extracted_text = []

for i, image in enumerate(images):
# Save image to temp file to reuse existing OCR logic
# Use delete=False so we can close it before OCR reads it (Windows compat, though running on Linux)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_img:
image.save(temp_img.name)
temp_img_path = temp_img.name

try:
# Reuse the existing OCR logic which handles preprocessing based on mode
page_text = await ocr.read_image(temp_img_path, lang=lang, mode=mode, auto=auto)
extracted_text.append(f"--- Page {i+1} ---\n{page_text}")
finally:
if os.path.exists(temp_img_path):
os.remove(temp_img_path)

return "\n\n".join(extracted_text)

except HTTPException as he:
raise he
except Exception as e:
# Log the error here if logging was set up
raise HTTPException(status_code=500, detail=f"Error processing PDF: {str(e)}")
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from fastapi import FastAPI
import sentry_sdk

from app.api import text_extract # updated
from app.api import text_extract, pdf_extract # updated
sentry_sdk.init(
dsn="https://5c74c0bf64424183a3d8fea7a803a9b0@o4505535984828416.ingest.sentry.io/4505535986335744",
traces_sample_rate=1.0,
Expand All @@ -32,6 +32,7 @@ def create_application() -> FastAPI:
lifespan=lifespan
)
application.include_router(text_extract.router)
application.include_router(pdf_extract.router)

return application

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"fastapi>=0.129.0",
"filetype>=1.2.0",
"httpx>=0.28.1",
"pdf2image>=1.17.0",
"pillow>=12.1.1",
"pydantic-settings>=2.13.0",
"pytesseract>=0.3.13",
Expand Down
15 changes: 14 additions & 1 deletion tests/test_file_validation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from fastapi import UploadFile
from app.domain.fileUpload import validate_image_file
from app.domain.fileUpload import validate_image_file, validate_pdf_file
from io import BytesIO
import pytest
from fastapi import HTTPException
Expand All @@ -24,3 +24,16 @@ def test_validate_empty_file():
validate_image_file(file)
assert exc.value.status_code == 400
assert "Empty file uploaded" in exc.value.detail

def test_validate_valid_pdf():
# Create a dummy PDF file (signature: %PDF-)
pdf_header = b'%PDF-1.4\n'
file = UploadFile(filename="test.pdf", file=BytesIO(pdf_header))
assert validate_pdf_file(file) is True

def test_validate_invalid_pdf():
# Create a text file
file = UploadFile(filename="test.txt", file=BytesIO(b"Hello World"))
with pytest.raises(HTTPException) as exc:
validate_pdf_file(file)
assert exc.value.status_code == 400
Loading
Loading