feat: initial release — pdf-toolkit v1.0.0
Core operations: - Compress PDF (3 presets: screen, ebook, printer, prepress) - PDF/A detection and conversion to regular PDF - Compress while preserving PDF/A structure - Merge multiple PDFs - Split/extract pages by range - Rotate pages - Extract text and images Interface: - Streamlit web GUI (app.py) - FastAPI REST API with auto OpenAPI docs (api.py) Docs: - README.md, ARCHITECTURE.md, API.md - Unit tests (15/15 passing)
This commit is contained in:
305
api.py
Normal file
305
api.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
pdf-toolkit API — FastAPI com documentação OpenAPI automática.
|
||||
|
||||
Endpoints REST para todas as operações do toolkit.
|
||||
Documentação interativa disponível em /docs (Swagger) e /redoc.
|
||||
|
||||
Uso:
|
||||
uvicorn api:app --host 0.0.0.0 --port 8555 --reload
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Query
|
||||
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
|
||||
|
||||
engine = PDFEngine()
|
||||
|
||||
# ── App ─────────────────────────────────────────────────────────────
|
||||
app = FastAPI(
|
||||
title=API_TITLE,
|
||||
version=API_VERSION,
|
||||
description=API_DESCRIPTION,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ── 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}"'},
|
||||
)
|
||||
|
||||
|
||||
# ── Endpoint Raiz ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"app": API_TITLE,
|
||||
"version": API_VERSION,
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc",
|
||||
}
|
||||
|
||||
|
||||
# ── Inspect ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/inspect", summary="Inspeciona metadados de um PDF")
|
||||
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
|
||||
|
||||
|
||||
# ── Compress ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/compress", summary="Comprime um PDF")
|
||||
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", f"_comprimido.pdf")
|
||||
return _pdf_response(result, out_name)
|
||||
|
||||
|
||||
# ── PDF/A → PDF comum ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/pdfa-to-regular", summary="Converte PDF/A para PDF comum")
|
||||
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 # já é PDF comum
|
||||
out_name = (file.filename or "documento.pdf").replace(".pdf", "_comum.pdf")
|
||||
return _pdf_response(result, out_name)
|
||||
|
||||
|
||||
# ── Compress PDF/A ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/compress-pdfa", summary="Comprime mantendo PDF/A")
|
||||
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)
|
||||
|
||||
|
||||
# ── Merge ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/merge", summary="Junta múltiplos PDFs em um")
|
||||
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")
|
||||
|
||||
|
||||
# ── Split ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/split", summary="Extrai intervalos de páginas de um PDF")
|
||||
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")
|
||||
|
||||
|
||||
# ── Extract pages ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/extract-pages", summary="Extrai páginas específicas")
|
||||
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)
|
||||
|
||||
|
||||
# ── Rotate ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/rotate", summary="Rotaciona páginas de um PDF")
|
||||
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)
|
||||
|
||||
|
||||
# ── Extract text ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/extract-text", summary="Extrai texto de um PDF")
|
||||
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"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Extract images ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.post("/extract-images", summary="Extrai imagens de um PDF como ZIP")
|
||||
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")
|
||||
|
||||
|
||||
# ── Health check ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check simples."""
|
||||
return {"status": "ok", "version": API_VERSION}
|
||||
|
||||
|
||||
# ── Entrypoint ──────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("api:app", host="0.0.0.0", port=8555, reload=True)
|
||||
Reference in New Issue
Block a user