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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
APP_NAME=""
APP_VERSION=""
APP_NAME="OCR PDF"
APP_VERSION="0.0.1"
APP_ORIGINS=http://127.0.0.1:8001
6 changes: 4 additions & 2 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ jobs:

- name: Smoke test container
run: |
docker run --rm -d --name test_app -p 8000:8000 myapp:test
docker run -d --name test_app -p 8000:8000 myapp:test
sleep 10
docker ps | grep test_app
docker ps -a
docker logs test_app
curl --fail http://localhost:8000/docs
docker rm -f test_app
28 changes: 10 additions & 18 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
# ========== Base ==========
FROM python:3.11-slim AS base

# Evita prompts e melhora logs
WORKDIR /app

ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

# Dependências do sistema para o ocrmypdf e Tesseract
# (inclui ghostscript, qpdf e idiomas pt/en)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ocrmypdf \
Expand All @@ -17,30 +15,24 @@ RUN apt-get update && \
ghostscript \
qpdf \
# utilitários úteis
curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
curl ca-certificates \
redis-tools \
&& rm -rf /var/lib/apt/lists/*

# ========== App ==========
WORKDIR /app

# Copia requirements primeiro para aproveitar cache de camadas
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /app/requirements.txt

# Copia o código da API (ajuste se tiver mais arquivos)
COPY app.py /app/app.py
COPY worker.py /app/worker.py
COPY tasks.py /app/tasks.py
COPY .env /app/.env

# Exponha a porta do Uvicorn
EXPOSE 8000

# Opcional: usuário sem privilégios
RUN useradd -m appuser
USER appuser

# Healthcheck simples (tente GET /health se você adicionou esse endpoint)
# HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -fsS http://localhost:8000/health || exit 1
# usuário sem privilégios
# RUN useradd -m appuser
# USER appuser

# Comando padrão (ajuste host/port conforme seu ambiente)
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
168 changes: 69 additions & 99 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,119 +1,36 @@
import uuid
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Literal

from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pypdf import PdfReader
from dotenv import load_dotenv
import os
from redis import Redis
from rq import Queue
from fastapi.responses import FileResponse
from tasks import process_ocr_job

load_dotenv()

load_dotenv() # Lê as variáveis do .env
origins_env = os.getenv("APP_ORIGINS", "")
allowed_origins = [o.strip() for o in origins_env.split(",") if o.strip()]

app = FastAPI(title=os.getenv("APP_NAME"), version=os.getenv("APP_VERSION"))

app = FastAPI(title="OCR API", version="1.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:8001",], # ajuste
allow_origins=[allowed_origins],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Conexão Redis e fila
redis_conn = Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379"))
q = Queue("ocr", connection=redis_conn)

def extract_text_with_pypdf(pdf_path: Path) -> str:
reader = PdfReader(str(pdf_path))
texts = []
for page in reader.pages:
try:
t = page.extract_text() or ""
except Exception:
t = ""
texts.append(t.strip())
return "\n\n".join(texts)


def run_ocrmypdf(src: Path, dst: Path, lang: str = "por+eng") -> None:
cmd = [
"ocrmypdf",
"--language", lang,
"--deskew",
"--optimize", "1",
"--force-ocr",
str(src),
str(dst),
]
completed = subprocess.run(cmd, capture_output=True, text=True)
if completed.returncode != 0:
raise RuntimeError(
f"OCR falhou (code {completed.returncode}). "
f"stderr: {completed.stderr.strip() or 'sem stderr'}"
)


@app.post("/ocr")
async def ocr_endpoint(
file: UploadFile = File(..., description="PDF para OCR"),
lang: str = Form("por+eng"),
response_type: Literal["json", "pdf"] = Form("pdf"),
):
ct = (file.content_type or "").lower()
if ct not in {"application/pdf", "application/x-pdf", "application/octet-stream"}:
raise HTTPException(status_code=400, detail=f"Tipo de arquivo não suportado: {ct or 'desconhecido'}")

try:
start_time = time.perf_counter()

with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)

# Nome do arquivo de saída
original_name = file.filename or "arquivo.pdf"
original_stem = Path(original_name).stem or "arquivo"
download_name = f"{original_stem}_ocr.pdf"

in_path = tmpdir / "entrada.pdf"
out_pdf = tmpdir / download_name

# Salva upload no disco
with in_path.open("wb") as f:
shutil.copyfileobj(file.file, f)

# Executa OCR
run_ocrmypdf(in_path, out_pdf, lang=lang)

elapsed = round(time.perf_counter() - start_time, 2) # segundos

if response_type == "json":
text_ocr = extract_text_with_pypdf(out_pdf)
pages = len(PdfReader(str(out_pdf)).pages)
return JSONResponse(
{
"filename_in": original_name,
"filename_out": download_name,
"lang": lang,
"pages": pages,
"elapsed_seconds": elapsed,
"text": text_ocr,
}
)

# PDF como resposta
headers = {
"Content-Disposition": f'attachment; filename="{download_name}"',
"X-Original-Filename": original_name,
"X-Processing-Time": f"{elapsed}s",
}
return StreamingResponse(out_pdf.open("rb"), media_type="application/pdf", headers=headers)

except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erro inesperado: {e}")
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "./uploads"))
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)


@app.get("/")
Expand All @@ -122,3 +39,56 @@ async def index():
"status": "success",
"message": f"{os.getenv('APP_NAME')} - v{os.getenv('APP_VERSION')}"
}


@app.post("/ocr")
async def enqueue_ocr(
file: UploadFile = File(...),
lang: str = Form("por+eng"),
response_type: str = Form("pdf")
):
# Salva o upload em disco para o worker ler
tmp_path = UPLOAD_DIR / f"{uuid.uuid4()}_{file.filename}"
with tmp_path.open("wb") as f:
shutil.copyfileobj(file.file, f)

job = q.enqueue(
process_ocr_job,
str(tmp_path),
lang,
response_type,
job_timeout=3600
)
return {"task_id": job.get_id(), "status": "queued"}


@app.get("/status/{task_id}")
async def job_status(task_id: str):
from rq.job import Job
try:
job = Job.fetch(task_id, connection=redis_conn)
except Exception:
raise HTTPException(status_code=404, detail="Job não encontrado")

if job.is_queued:
return {"status": "queued"}
if job.is_started:
return {"status": "processing"}
if job.is_finished:
result = job.result
download_url = f"/download/{result['filename_out']}"
return {"status": "done", "result": result, "download_url": download_url}
if job.is_failed:
return {"status": "failed", "error": str(job.exc_info)}


@app.get("/download/{filename}")
async def download_file(filename: str):
file_path = UPLOAD_DIR / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="Arquivo não encontrado")
return FileResponse(
file_path,
media_type="application/pdf",
filename=filename
)
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ python-dotenv
uvicorn
fastapi
python-multipart
rq
redis
74 changes: 74 additions & 0 deletions tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import os
import time
import subprocess
import tempfile
from pathlib import Path
from pypdf import PdfReader


def extract_text_with_pypdf(pdf_path: Path) -> str:
reader = PdfReader(str(pdf_path))
texts = []
for page in reader.pages:
try:
t = page.extract_text() or ""
except Exception:
t = ""
texts.append(t.strip())
return "\n\n".join(texts)


def run_ocrmypdf(src: Path, dst: Path, lang: str = "por+eng") -> None:
cmd = [
"ocrmypdf",
"--language", lang,
"--deskew",
"--optimize", "1",
"--force-ocr",
str(src),
str(dst),
]
completed = subprocess.run(cmd, capture_output=True, text=True)
if completed.returncode != 0:
raise RuntimeError(
f"OCR falhou (code {completed.returncode}). "
f"stderr: {completed.stderr.strip() or 'sem stderr'}"
)


def process_ocr_job(file_path: str, lang: str, response_type: str):
"""
Função executada pelo worker RQ.
Recebe o caminho do arquivo salvo pelo endpoint.
"""
start = time.perf_counter()

shared_dir = Path(os.getenv("UPLOAD_DIR", "/shared_uploads"))
shared_dir.mkdir(parents=True, exist_ok=True)

original_path = Path(file_path)
download_name = f"{original_path.stem}_ocr.pdf"
out_pdf = shared_dir / download_name # <- grava no volume compartilhado

run_ocrmypdf(original_path, out_pdf, lang=lang)

elapsed = round(time.perf_counter() - start, 2)

if response_type == "json":
text_ocr = extract_text_with_pypdf(out_pdf)
pages = len(PdfReader(str(out_pdf)).pages)
return {
"status": "done",
"filename_in": original_path.name,
"filename_out": download_name, # apenas o nome do arquivo
"elapsed_seconds": elapsed,
"pages": pages,
"text": text_ocr,
}
else:
return {
"status": "done",
"filename_in": original_path.name,
"filename_out": download_name,
"elapsed_seconds": elapsed,
}
13 changes: 13 additions & 0 deletions worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import os
from redis import Redis
from rq import Worker, Queue

listen = ["ocr"]
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
conn = Redis.from_url(redis_url)

if __name__ == "__main__":
# Cria as filas e inicia o worker usando a conexão direta
queues = [Queue(name, connection=conn) for name in listen]
worker = Worker(queues, connection=conn)
worker.work()