""" pdf-toolkit API — FastAPI com documentação OpenAPI automática e autenticação. Endpoints REST para todas as operações do toolkit. Documentação interativa disponível em /docs (Swagger) e /redoc. Autenticação via header X-API-Key. Variável de ambiente: PDFTOOLKIT_API_KEY Se não definida, a API roda sem autenticação (modo dev). Uso: uvicorn api:app --host 0.0.0.0 --port 8555 --reload """ import io import os import zipfile from typing import List from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Depends from fastapi.responses import Response, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from config import API_TITLE, API_VERSION, API_DESCRIPTION, MAX_UPLOAD_SIZE_BYTES, QUALITY_PRESETS from core.engine import PDFEngine from core.auth import verify_api_key, is_auth_enabled engine = PDFEngine() # ── App ───────────────────────────────────────────────────────────── app = FastAPI( title=API_TITLE, version=API_VERSION, description=API_DESCRIPTION, # A API é publicada externamente atrás de /v1 pelo Traefik. # root_path corrige as URLs do Swagger/ReDoc/OpenAPI nesse cenário. root_path=os.getenv("API_ROOT_PATH", ""), docs_url="/docs", redoc_url="/redoc", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ── Dependência de autenticação ───────────────────────────────────── # Endpoints públicos (health, root) usam auth opcional # Endpoints de operação usam auth obrigatório (se configurado) def _auth_required(api_key: str = Depends(verify_api_key)): """Dependência que aplica autenticação nos endpoints operacionais.""" return api_key # ── Helpers ───────────────────────────────────────────────────────── def _read_file(file: UploadFile) -> bytes: data = file.file.read() if len(data) > MAX_UPLOAD_SIZE_BYTES: raise HTTPException(413, f"Arquivo excede o limite de {MAX_UPLOAD_SIZE_BYTES / 1024 / 1024:.0f} MB.") return data def _pdf_response(data: bytes, filename: str) -> Response: return Response( content=data, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) def _zip_response(files: List[tuple], zip_name: str = "resultados.zip") -> StreamingResponse: buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for nome, dados in files: zf.writestr(nome, dados) buf.seek(0) return StreamingResponse( buf, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, ) # ── Endpoints Públicos (sem auth) ─────────────────────────────────── @app.get("/") def root(): auth_info = {"authentication": "enabled"} if is_auth_enabled() else {"authentication": "disabled (dev mode)"} return { "app": API_TITLE, "version": API_VERSION, "docs": "/docs", "redoc": "/redoc", **auth_info, } @app.api_route("/health", methods=["GET", "HEAD"]) def health(): """Health check — sem autenticação. Suporta GET e HEAD (Docker Swarm).""" from fastapi.responses import JSONResponse return JSONResponse( content={"status": "ok"}, headers={ "X-Api-Status": "ok", "X-Api-Version": API_VERSION, }, ) # ── Endpoints Protegidos ──────────────────────────────────────────── @app.post( "/inspect", summary="Inspeciona metadados de um PDF", dependencies=[Depends(_auth_required)], ) def inspect(file: UploadFile = File(...)): """ Retorna metadados detalhados do PDF: número de páginas, tamanho, detecção de PDF/A, validade. """ data = _read_file(file) info = engine.inspect(data, file.filename or "documento.pdf") if not info["valid"]: raise HTTPException(400, info["error"]) return info @app.post( "/compress", summary="Comprime um PDF", dependencies=[Depends(_auth_required)], ) def compress( file: UploadFile = File(...), preset: str = Form("ebook"), ): """ Comprime um PDF re-amostrando imagens. - **preset**: screen | ebook | printer | prepress """ if preset not in QUALITY_PRESETS: raise HTTPException(400, f"Preset inválido. Opções: {', '.join(QUALITY_PRESETS.keys())}") data = _read_file(file) result = engine.compress(data, preset=preset) out_name = (file.filename or "documento.pdf").replace(".pdf", "_comprimido.pdf") return _pdf_response(result, out_name) @app.post( "/pdfa-to-regular", summary="Converte PDF/A para PDF comum", dependencies=[Depends(_auth_required)], ) def pdfa_to_regular(file: UploadFile = File(...)): """Remove restrições PDF/A gerando um PDF comum padrão.""" data = _read_file(file) info = engine.inspect(data) if info.get("is_pdfa"): result = engine.pdfa_to_regular(data) else: result = data out_name = (file.filename or "documento.pdf").replace(".pdf", "_comum.pdf") return _pdf_response(result, out_name) @app.post( "/compress-pdfa", summary="Comprime mantendo PDF/A", dependencies=[Depends(_auth_required)], ) def compress_pdfa( file: UploadFile = File(...), quality: int = Form(60, ge=10, le=100), ): """Comprime as imagens internas preservando a estrutura PDF/A.""" data = _read_file(file) result = engine.compress_pdfa(data, quality=quality) out_name = (file.filename or "documento.pdf").replace(".pdf", "_otimizado.pdf") return _pdf_response(result, out_name) @app.post( "/merge", summary="Junta múltiplos PDFs em um", dependencies=[Depends(_auth_required)], ) async def merge(files: List[UploadFile] = File(...)): """Mescla 2 ou mais PDFs em ordem, retornando um único PDF.""" if len(files) < 2: raise HTTPException(400, "Informe ao menos 2 PDFs.") inputs = [] for f in files: data = _read_file(f) inputs.append((data, f.filename or "documento.pdf")) result = engine.merge(inputs) return _pdf_response(result, "merged.pdf") @app.post( "/split", summary="Extrai intervalos de páginas de um PDF", dependencies=[Depends(_auth_required)], ) def split( file: UploadFile = File(...), ranges: str = Form(..., description='Ex: "1-3,5,7-9"'), ): """ Divide um PDF em múltiplos PDFs baseado em intervalos de páginas. - **ranges**: string com intervalos. Ex: "1-3,5,7-9" (1-indexado) """ data = _read_file(file) reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data)) total = len(reader.pages) parts = ranges.replace(" ", "").split(",") page_ranges = [] for part in parts: if "-" in part: a, b = part.split("-") start = max(0, int(a) - 1) end = min(int(b) - 1, total - 1) page_ranges.append((start, end)) else: idx = int(part) - 1 if 0 <= idx < total: page_ranges.append((idx, idx)) if not page_ranges: raise HTTPException(400, "Nenhum intervalo válido informado.") base = (file.filename or "pagina").replace(".pdf", "") results = engine.split(data, page_ranges, base) return _zip_response(results, "split.zip") @app.post( "/extract-pages", summary="Extrai páginas específicas", dependencies=[Depends(_auth_required)], ) def extract_pages( file: UploadFile = File(...), pages: str = Form(..., description='Ex: "1,3,5" (1-indexado)'), ): """Extrai páginas específicas de um PDF em ordem.""" data = _read_file(file) reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data)) total = len(reader.pages) indices = [] for p in pages.replace(" ", "").split(","): p = p.strip() if not p: continue idx = int(p) - 1 if 0 <= idx < total: indices.append(idx) if not indices: raise HTTPException(400, "Nenhuma página válida informada.") result = engine.extract(data, indices) out_name = (file.filename or "documento.pdf").replace(".pdf", "_extraido.pdf") return _pdf_response(result, out_name) @app.post( "/rotate", summary="Rotaciona páginas de um PDF", dependencies=[Depends(_auth_required)], ) def rotate( file: UploadFile = File(...), pages: str = Form(..., description='Páginas: "1,3,5" ou "todas"'), angle: int = Form(90, ge=90, le=270), ): """Rotaciona páginas em 90, 180 ou 270 graus.""" data = _read_file(file) reader = __import__("pypdf", fromlist=["PdfReader"]).PdfReader(io.BytesIO(data)) total = len(reader.pages) if pages.lower() in ("", "todas", "all"): page_indices = list(range(total)) else: page_indices = [int(p.strip()) - 1 for p in pages.replace(" ", "").split(",")] page_indices = [p for p in page_indices if 0 <= p < total] rotations = [(p, angle) for p in page_indices] result = engine.rotate(data, rotations) out_name = (file.filename or "documento.pdf").replace(".pdf", "_rotacionado.pdf") return _pdf_response(result, out_name) @app.post( "/extract-text", summary="Extrai texto de um PDF", dependencies=[Depends(_auth_required)], ) def extract_text_endpoint(file: UploadFile = File(...)): """Extrai todo o texto legível de um PDF e retorna como .txt.""" data = _read_file(file) text = engine.text(data) return Response( content=text, media_type="text/plain; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{file.filename or "documento"}.txt"' }, ) @app.post( "/extract-images", summary="Extrai imagens de um PDF como ZIP", dependencies=[Depends(_auth_required)], ) def extract_images_endpoint(file: UploadFile = File(...)): """Extrai todas as imagens embutidas no PDF e retorna um ZIP.""" data = _read_file(file) images = engine.images(data) if not images: raise HTTPException(404, "Nenhuma imagem encontrada neste PDF.") return _zip_response(images, "imagens.zip") # ── Entrypoint ────────────────────────────────────────────────────── if __name__ == "__main__": import uvicorn uvicorn.run("api:app", host="0.0.0.0", port=8555, reload=True)