Files
pdf-toolkit/api.py
CM Consultor 84d4a1eab5 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)
2026-08-09 02:15:22 +00:00

305 lines
11 KiB
Python

"""
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)