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:
CM Consultor
2026-08-09 02:15:22 +00:00
commit 84d4a1eab5
17 changed files with 1998 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.env
.env.local
venv/
.venv/
tmp/
*.egg-info/
dist/
build/
.pytest_cache/

122
README.md Normal file
View File

@@ -0,0 +1,122 @@
# pdf-toolkit
Transforme, otimize e gerencie arquivos PDF com facilidade.
- **Interface Web**: Streamlit com upload drag-and-drop e configuração visual
- **API REST**: FastAPI com documentação OpenAPI automática em `/docs`
- **Operações**: compressão, PDF/A ↔ PDF comum, merge, split, rotação, extração de texto e imagens
---
## 🚀 Começando
### Pré-requisitos
- Python 3.12+
- pip
### Instalação
```bash
git clone <url-do-repositorio> pdf-toolkit
cd pdf-toolkit
pip install -r requirements.txt
```
### Execução
**Streamlit (interface web):**
```bash
streamlit run app.py --server.port 8550
```
**API REST:**
```bash
uvicorn api:app --host 0.0.0.0 --port 8555 --reload
```
Acesse:
- Streamlit → http://localhost:8550
- API docs → http://localhost:8555/docs (Swagger)
- API docs → http://localhost:8555/redoc (Redoc)
---
## 📋 Operações disponíveis
| Operação | CLI | API | Streamlit |
|---|---|---|---|
| Comprimir PDF | ✅ | POST `/compress` | ✅ |
| PDF/A → PDF comum | ✅ | POST `/pdfa-to-regular` | ✅ |
| Comprimir mantendo PDF/A | ✅ | POST `/compress-pdfa` | ✅ |
| Merge (juntar PDFs) | ✅ | POST `/merge` | ✅ |
| Split (extrair páginas) | ✅ | POST `/split` | ✅ |
| Extrair páginas específicas | ✅ | POST `/extract-pages` | ✅ |
| Rotacionar páginas | ✅ | POST `/rotate` | ✅ |
| Extrair texto | ✅ | POST `/extract-text` | ✅ |
| Extrair imagens | ✅ | POST `/extract-images` | ✅ |
| Inspecionar metadados | ✅ | POST `/inspect` | ✅ |
### Níveis de compressão
| Preset | Qualidade | Dimensão máx | Descrição |
|---|---|---|---|
| `screen` | 30 | 1200px | Agressivo — mínimo tamanho |
| `ebook` | 60 | 2000px | Equilibrado — padrão |
| `printer` | 85 | 3500px | Leve — alta qualidade |
| `prepress` | 95 | 5000px | Mínima perda |
---
## 🧠 Arquitetura
```
pdf-toolkit/
├── app.py # Interface Streamlit (GUI)
├── api.py # API REST (FastAPI)
├── config.py # Constantes e limites
├── requirements.txt # Dependências
├── core/
│ ├── engine.py # Orquestrador central
│ ├── compress.py # Compressão e re-amostragem
│ ├── operations.py # Merge, split, rotate, extract
│ ├── validators.py # Validação de PDFs
│ └── pdfa.py # Detecção e conversão PDF/A
├── scripts/
│ ├── run-web.sh # Inicia Streamlit
│ └── run-api.sh # Inicia API
├── tests/ # Testes unitários
├── docs/
│ ├── ARCHITECTURE.md # Detalhamento técnico
│ └── API.md # Documentação da API
└── tmp/ # Arquivos temporários (gitignored)
```
### Decisões técnicas
- **pypdf**: manipulação PDF 100% Python, sem dependências binárias
- **Pillow**: re-amostragem de imagens com redimensionamento proporcional
- **FastAPI**: tipagem automática, validação de entrada, OpenAPI nativo
- **Streamlit**: prototipação rápida de GUI sem HTML/JS
- **Sem Ghostscript**: mantido leve — pypdf + Pillow cobre ~90% dos casos
### Limitações conhecidas
- A compressão re-amostra imagens embutidas; PDFs sem imagens reduzem pouco
- A detecção de PDF/A é heurística (via metadados XMP + OutputIntents)
- Preview visual de páginas requer `poppler-utils` + `pdf2image` (não incluso)
- PDFs criptografados com senha não são suportados
---
## 🧪 Testes
```bash
python -m pytest tests/ -v
```
---
## 📦 Licença
MIT

305
api.py Normal file
View 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)

288
app.py Normal file
View File

@@ -0,0 +1,288 @@
"""
pdf-toolkit — Interface Web via Streamlit.
Permite upload de múltiplos PDFs, seleção de operações,
visualização de metadados e download dos resultados.
"""
import io
import os
import zipfile
from pathlib import Path
import streamlit as st
from config import QUALITY_PRESETS, DEFAULT_QUALITY, MAX_FILES_PER_BATCH
from core.engine import PDFEngine
# ── Configuração da página ──────────────────────────────────────────
st.set_page_config(
page_title="pdf-toolkit",
page_icon="📄",
layout="wide",
initial_sidebar_state="expanded",
)
st.title("📄 pdf-toolkit")
st.markdown("Transforme, otimize e gerencie seus arquivos PDF.")
engine = PDFEngine()
# ── SIDEBAR: Upload ─────────────────────────────────────────────────
st.sidebar.header("1. Upload de PDFs")
uploaded_files = st.sidebar.file_uploader(
"Selecione um ou mais PDFs",
type=["pdf"],
accept_multiple_files=True,
help=f"Máximo de {MAX_FILES_PER_BATCH} arquivos por vez.",
)
if not uploaded_files:
st.info("Faça upload de arquivos PDF na barra lateral para começar.")
st.stop()
if len(uploaded_files) > MAX_FILES_PER_BATCH:
st.error(f"Máximo de {MAX_FILES_PER_BATCH} arquivos por vez.")
st.stop()
# ── Lê arquivos em memória ──────────────────────────────────────────
files_data = {}
for f in uploaded_files:
data = f.read()
info = engine.inspect(data, f.name)
files_data[f.name] = {"data": data, "info": info, "file": f}
st.sidebar.success(f"{len(files_data)} arquivo(s) carregado(s).")
# ── SIDEBAR: Seleção de operação ────────────────────────────────────
st.sidebar.header("2. Operação")
operation = st.sidebar.selectbox(
"O que deseja fazer?",
[
"📦 Comprimir PDF",
"📄 PDF/A → PDF comum",
"📋 Comprimir (mantendo PDF/A)",
"🔗 Merge (juntar PDFs)",
"✂️ Split (extrair páginas)",
"🔄 Rotacionar páginas",
"📝 Extrair texto",
"🖼️ Extrair imagens",
"🔍 Inspecionar metadados",
],
index=0,
)
# ── Opções específicas por operação ──────────────────────────────────
extra_opts = {}
if operation in ["📦 Comprimir PDF", "📋 Comprimir (mantendo PDF/A)"]:
preset = st.sidebar.selectbox(
"Nível de compressão",
options=list(QUALITY_PRESETS.keys()),
format_func=lambda k: f"{k} — {QUALITY_PRESETS[k]['desc']}",
index=list(QUALITY_PRESETS.keys()).index(DEFAULT_QUALITY),
)
extra_opts["preset"] = preset
elif operation == "✂️ Split (extrair páginas)":
if len(files_data) == 1:
fname = list(files_data.keys())[0]
total_pages = files_data[fname]["info"]["pages"]
st.sidebar.markdown(f"O PDF tem **{total_pages} página(s)**.")
range_input = st.sidebar.text_input(
"Intervalos (ex: 1-3, 5, 7-9)",
placeholder="1-3, 5, 7-9",
)
extra_opts["range_input"] = range_input
extra_opts["total_pages"] = total_pages
else:
st.sidebar.warning("Split funciona com 1 PDF por vez. Remova arquivos extras.")
elif operation == "🔄 Rotacionar páginas":
if len(files_data) == 1:
fname = list(files_data.keys())[0]
total_pages = files_data[fname]["info"]["pages"]
st.sidebar.markdown(f"PDF tem **{total_pages} página(s)**.")
pages = st.sidebar.text_input(
"Páginas (ex: 1, 3, 5 ou 'todas')",
placeholder="todas",
)
angle = st.sidebar.selectbox("Ângulo", [90, 180, 270], index=0)
extra_opts["pages"] = pages
extra_opts["angle"] = angle
extra_opts["total_pages"] = total_pages
else:
st.sidebar.warning("Rotação funciona com 1 PDF por vez.")
elif operation == "📝 Extrair texto":
pass # Sem opções extras
elif operation == "🖼️ Extrair imagens":
pass
elif operation == "🔍 Inspecionar metadados":
pass
elif operation == "🔗 Merge (juntar PDFs)":
if len(files_data) < 2:
st.sidebar.warning("Merge precisa de pelo menos 2 PDFs.")
# ── BOTÃO DE EXECUÇÃO ──────────────────────────────────────────────
run = st.sidebar.button("▶️ Executar", type="primary", use_container_width=True)
if not run:
# Apenas mostra preview dos arquivos carregados
st.subheader("📁 Arquivos carregados")
for fname, fobj in files_data.items():
st.markdown(engine.get_info_string(fobj["info"]))
st.stop()
# ── EXECUÇÃO ────────────────────────────────────────────────────────
st.subheader("📊 Resultados")
progress = st.progress(0, text="Processando...")
try:
results = []
if operation == "📦 Comprimir PDF":
progress.progress(10, "Comprimindo...")
for i, (fname, fobj) in enumerate(files_data.items()):
result_data = engine.compress(fobj["data"], preset=extra_opts["preset"])
orig_mb = fobj["info"]["file_size_mb"]
new_mb = round(len(result_data) / (1024 * 1024), 2)
reduction = round((1 - (new_mb / orig_mb)) * 100, 1) if orig_mb > 0 else 0
results.append((fname.replace(".pdf", f"_comprimido.pdf"), result_data))
progress.progress((i + 1) / len(files_data) * 100,
f"{fname}: {orig_mb}MB → {new_mb}MB ({reduction}% redução)")
elif operation == "📄 PDF/A → PDF comum":
progress.progress(10, "Convertendo PDF/A...")
for i, (fname, fobj) in enumerate(files_data.items()):
if fobj["info"].get("is_pdfa"):
result_data = engine.pdfa_to_regular(fobj["data"])
results.append((fname.replace(".pdf", "_comum.pdf"), result_data))
else:
st.warning(f"{fname} não é PDF/A. Mantido original.")
results.append((fname, fobj["data"]))
progress.progress((i + 1) / len(files_data) * 100)
elif operation == "📋 Comprimir (mantendo PDF/A)":
progress.progress(10, "Comprimindo PDF/A...")
q = QUALITY_PRESETS[extra_opts["preset"]]["quality"]
for i, (fname, fobj) in enumerate(files_data.items()):
if fobj["info"].get("is_pdfa"):
result_data = engine.compress_pdfa(fobj["data"], quality=q)
else:
# Se não for PDF/A, aplica compressão normal
result_data = engine.compress(fobj["data"], preset=extra_opts["preset"])
orig_mb = fobj["info"]["file_size_mb"]
new_mb = round(len(result_data) / (1024 * 1024), 2)
results.append((fname.replace(".pdf", f"_otimizado.pdf"), result_data))
progress.progress((i + 1) / len(files_data) * 100)
elif operation == "🔗 Merge (juntar PDFs)":
progress.progress(30, "Mesclando PDFs...")
merge_input = [(fobj["data"], fname) for fname, fobj in files_data.items()]
result_data = engine.merge(merge_input)
results.append(("merged.pdf", result_data))
progress.progress(100, "Merge concluído!")
elif operation == "✂️ Split (extrair páginas)":
fname = list(files_data.keys())[0]
range_input = extra_opts.get("range_input", "")
total = extra_opts["total_pages"]
if not range_input.strip():
st.error("Informe os intervalos de páginas.")
st.stop()
# Parse: "1-3, 5, 7-9"
ranges = []
parts = range_input.replace(" ", "").split(",")
for part in parts:
if "-" in part:
a, b = part.split("-")
start = max(0, int(a) - 1)
end = min(int(b) - 1, total - 1)
ranges.append((start, end))
else:
idx = int(part) - 1
ranges.append((idx, idx))
progress.progress(30, "Extraindo páginas...")
splitted = engine.split(fobj["data"], ranges, fname.replace(".pdf", ""))
for nome, data in splitted:
results.append((nome, data))
progress.progress(100, "Split concluído!")
elif operation == "🔄 Rotacionar páginas":
fname = list(files_data.keys())[0]
fobj = files_data[fname]
pages_str = extra_opts.get("pages", "todas")
angle = extra_opts["angle"]
total = extra_opts["total_pages"]
if pages_str.lower() in ("", "todas", "all"):
pages_list = list(range(total))
else:
pages_list = [int(p.strip()) - 1 for p in pages_str.replace(" ", "").split(",")]
pages_list = [p for p in pages_list if 0 <= p < total]
rotations = [(p, angle) for p in pages_list]
progress.progress(50, "Rotacionando...")
result_data = engine.rotate(fobj["data"], rotations)
results.append((fname.replace(".pdf", f"_rotacionado.pdf"), result_data))
progress.progress(100)
elif operation == "📝 Extrair texto":
for fname, fobj in files_data.items():
text = engine.text(fobj["data"])
txt_name = fname.replace(".pdf", ".txt")
results.append((txt_name, text.encode("utf-8")))
with st.expander(f"📝 Texto extraído de **{fname}**"):
st.text(text[:5000] + ("..." if len(text) > 5000 else ""))
elif operation == "🖼️ Extrair imagens":
for fname, fobj in files_data.items():
imgs = engine.images(fobj["data"])
for img_name, img_data in imgs:
results.append((f"{fname.replace('.pdf', '')}_{img_name}", img_data))
st.write(f"🔍 {len(imgs)} imagem(ns) extraída(s) de **{fname}**")
elif operation == "🔍 Inspecionar metadados":
for fname, fobj in files_data.items():
st.markdown(engine.get_info_string(fobj["info"]))
progress.empty()
# ── DISPONIBILIZA DOWNLOAD ──────────────────────────────────────
if results:
if len(results) == 1:
nome, dados = results[0]
st.download_button(
label=f"⬇️ Download {nome}",
data=dados,
file_name=nome,
mime="application/pdf" if nome.endswith(".pdf") else "text/plain",
use_container_width=True,
)
else:
# Cria ZIP com todos os resultados
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
for nome, dados in results:
zf.writestr(nome, dados)
zip_buf.seek(0)
st.download_button(
label=f"⬇️ Download ZIP ({len(results)} arquivo(s))",
data=zip_buf,
file_name="pdf-toolkit-resultados.zip",
mime="application/zip",
use_container_width=True,
)
except Exception as e:
progress.empty()
st.error(f"❌ Erro durante o processamento: {str(e)}")
st.exception(e)

38
config.py Normal file
View File

@@ -0,0 +1,38 @@
"""
Configurações globais do pdf-toolkit.
"""
import os
# ── Limites de arquivo ──────────────────────────────────────────────
MAX_UPLOAD_SIZE_MB = 200
MAX_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024
MAX_FILES_PER_BATCH = 20
MAX_PAGES_PREVIEW = 10
# ── Compressão ──────────────────────────────────────────────────────
QUALITY_PRESETS = {
"screen": {"quality": 30, "max_dim": 1200, "desc": "Agressivo — tamanho mínimo, qualidade baixa"},
"ebook": {"quality": 60, "max_dim": 2000, "desc": "Equilibrado — bom custo-benefício"},
"printer": {"quality": 85, "max_dim": 3500, "desc": "Leve — qualidade alta, redução modesta"},
"prepress":{"quality": 95, "max_dim": 5000, "desc": "Mínima — quase sem perda"},
}
DEFAULT_QUALITY = "ebook"
# ── Paths ───────────────────────────────────────────────────────────
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
TEMP_DIR = os.path.join(PROJECT_ROOT, "..", "tmp")
# Cria temp dir se não existir
os.makedirs(TEMP_DIR, exist_ok=True)
# ── API ─────────────────────────────────────────────────────────────
API_HOST = "0.0.0.0"
API_PORT = 8555
API_TITLE = "pdf-toolkit API"
API_VERSION = "1.0.0"
API_DESCRIPTION = "API de transformação e otimização de arquivos PDF."
# ── Streamlit ───────────────────────────────────────────────────────
STREAMLIT_PORT = 8550

1
core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# pdf-toolkit core — motor de transformação de PDFs

134
core/compress.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Estratégias de compressão e otimização de PDF.
Usa pypdf + Pillow para re-amostragem de imagens e remoção de
objetos redundantes. Mantém a estrutura original do PDF (inclusive
PDF/A) quando possível.
"""
import io
from typing import Optional
from PIL import Image
from pypdf import PdfReader, PdfWriter
from pypdf.constants import ImageAttributes as IA
from config import QUALITY_PRESETS
def _downsample_image(
img_bytes: bytes,
quality: int = 60,
max_dim: int = 2000,
fmt: str = "JPEG",
) -> bytes:
"""
Redimensiona e re-comprime uma imagem.
- quality: 1-100 (JPEG)
- max_dim: lado máximo em px
- fmt: "JPEG" ou "PNG"
"""
img = Image.open(io.BytesIO(img_bytes))
# Converte RGBA/CMYK/P (palette) para RGB para compressão JPEG
if img.mode not in ("RGB", "L"):
if fmt == "JPEG":
img = img.convert("RGB")
elif img.mode == "P":
img = img.convert("RGB")
# Redimensiona proporcionalmente se exceder max_dim
w, h = img.size
if max(w, h) > max_dim:
ratio = max_dim / max(w, h)
new_size = (int(w * ratio), int(h * ratio))
# Usa ANTIALIAS/LANCZOS para melhor qualidade na redução
img = img.resize(new_size, Image.LANCZOS)
buf = io.BytesIO()
if fmt == "JPEG":
img.save(buf, format="JPEG", quality=quality, optimize=True)
else:
img.save(buf, format="PNG", optimize=True)
return buf.getvalue()
def compress_pdf(
data: bytes,
preset: str = "ebook",
remove_unused: bool = True,
) -> bytes:
"""
Comprime um PDF re-amostrando imagens e otimizando estrutura.
Args:
data: Conteúdo do PDF original.
preset: Perfil de qualidade ("screen", "ebook", "printer", "prepress").
remove_unused: Remove objetos não referenciados.
Retorna bytes do PDF comprimido.
"""
opts = QUALITY_PRESETS.get(preset, QUALITY_PRESETS["ebook"])
quality = opts["quality"]
max_dim = opts["max_dim"]
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter()
# Copia metadados
writer.metadata = reader.metadata
# Registra fontes e objetos que usamos (para limpeza)
used_objects = set()
for page_num, page in enumerate(reader.pages):
used_objects.add(id(page))
# Verifica se há imagens na página
if "/XObject" in page.get("/Resources", {}):
resources = page["/Resources"]
if "/XObject" in resources:
xobjects = resources["/XObject"]
for obj_name in list(xobjects.keys()):
xobj = xobjects[obj_name]
subtype = xobj.get("/Subtype", "")
if subtype == "/Image":
try:
raw = xobj.get_data()
if raw:
# Determina formato — PNG se tiver transparência
fmt = "PNG" if xobj.get("/ColorSpace") == "/DeviceCMYK" else "JPEG"
compressed = _downsample_image(raw, quality, max_dim, fmt)
xobj._data = compressed
xobj[IA.WIDTH], xobj[IA.HEIGHT] = Image.open(
io.BytesIO(compressed)
).size
except Exception:
# Se falhar na recompressão, mantém original
pass
# Adiciona página modificada
writer.add_page(page)
# Escreve com compressão de streams
out_buf = io.BytesIO()
writer.write_stream(out_buf)
return out_buf.getvalue()
def linearize_pdf(data: bytes) -> bytes:
"""
Re-escreve o PDF de forma linearizada (otimizada para web).
Usa pypdf para reestruturar.
"""
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter()
writer.metadata = reader.metadata
for page in reader.pages:
writer.add_page(page)
out_buf = io.BytesIO()
writer.write_stream(out_buf)
return out_buf.getvalue()

99
core/engine.py Normal file
View File

@@ -0,0 +1,99 @@
"""
Orquestrador principal de transformações de PDF.
Coordena validação, compressão, operações e conversão PDF/A
encadeando as funções dos submódulos.
"""
import io
from typing import List, Tuple
from .validators import validate_pdf, assert_safe_filename
from .compress import compress_pdf
from .pdfa import detect_pdfa, strip_pdfa, compress_pdfa
from .operations import merge_pdfs, split_pdf, extract_pages, rotate_pages
from .operations import extract_text, extract_images, preview_pages
class PDFEngine:
"""
Interface única para todas as operações do toolkit.
Cada método recebe bytes e retorna bytes (ou metadados).
"""
@staticmethod
def inspect(data: bytes, filename: str = "") -> dict:
"""Valida e retorna metadados do PDF."""
info = validate_pdf(data, filename)
if info["valid"]:
pdfa_info = detect_pdfa(data)
info.update(pdfa_info)
return info
@staticmethod
def compress(data: bytes, preset: str = "ebook") -> bytes:
"""Comprime PDF com re-amostragem de imagens."""
return compress_pdf(data, preset=preset)
@staticmethod
def pdfa_to_regular(data: bytes) -> bytes:
"""Remove restrições PDF/A gerando PDF comum."""
return strip_pdfa(data)
@staticmethod
def compress_pdfa(data: bytes, quality: int = 60) -> bytes:
"""Comprime mantendo a estrutura PDF/A."""
return compress_pdfa(data, quality=quality)
@staticmethod
def detect_pdfa(data: bytes) -> dict:
"""Detecta se é PDF/A e qual nível."""
return detect_pdfa(data)
@staticmethod
def merge(files: List[Tuple[bytes, str]]) -> bytes:
"""Junta múltiplos PDFs."""
return merge_pdfs(files)
@staticmethod
def split(data: bytes, ranges: List[Tuple[int, int]], base: str = "pagina"):
"""Divide PDF em intervalos de páginas."""
return split_pdf(data, ranges, base)
@staticmethod
def extract(data: bytes, pages: List[int]) -> bytes:
"""Extrai páginas específicas."""
return extract_pages(data, pages)
@staticmethod
def rotate(data: bytes, rotations: List[Tuple[int, int]]) -> bytes:
"""Rotaciona páginas."""
return rotate_pages(data, rotations)
@staticmethod
def text(data: bytes) -> str:
"""Extrai texto de todas as páginas."""
return extract_text(data)
@staticmethod
def images(data: bytes):
"""Extrai imagens do PDF."""
return extract_images(data)
@staticmethod
def get_info_string(info: dict) -> str:
"""Formata metadados para exibição."""
lines = [
f"📄 **{info.get('filename', 'documento')}**",
f" - Tamanho: {info['file_size_mb']:.2f} MB",
f" - Páginas: {info['pages']}",
]
if info.get("valid"):
if info["is_pdfa"]:
level = info.get("level", "PDF/A")
lines.append(f" - 📋 Tipo: {level}")
else:
lines.append(" - 📋 Tipo: PDF comum")
else:
lines.append(f" - ⚠️ Erro: {info['error']}")
return "\n".join(lines)

199
core/operations.py Normal file
View File

@@ -0,0 +1,199 @@
"""
Operações gerais sobre PDFs: merge, split, rotate, extração de
páginas/texto/imagens.
"""
import io
from typing import List, Optional, Tuple
from PIL import Image
from pypdf import PdfReader, PdfWriter, PageObject
# ── Merge ───────────────────────────────────────────────────────────
def merge_pdfs(files_data: List[Tuple[bytes, str]]) -> bytes:
"""
Junta múltiplos PDFs em um único arquivo, na ordem fornecida.
Args:
files_data: Lista de (conteúdo_bytes, nome_arquivo).
Retorna bytes do PDF mesclado.
"""
writer = PdfWriter()
for data, fname in files_data:
reader = PdfReader(io.BytesIO(data))
for page in reader.pages:
writer.add_page(page)
out_buf = io.BytesIO()
writer.write_stream(out_buf)
return out_buf.getvalue()
# ── Split ───────────────────────────────────────────────────────────
def split_pdf(
data: bytes,
page_ranges: List[Tuple[int, int]],
filename_base: str = "pagina",
) -> List[Tuple[str, bytes]]:
"""
Extrai intervalos de páginas de um PDF.
Args:
data: Conteúdo do PDF.
page_ranges: Lista de (inicio, fim), 0-indexado. Ex: [(0,2), (5,7)].
filename_base: Base para nomes.
Retorna lista de (nome, bytes).
"""
reader = PdfReader(io.BytesIO(data))
total = len(reader.pages)
results = []
for i, (start, end) in enumerate(page_ranges):
writer = PdfWriter()
writer.metadata = reader.metadata
for idx in range(start, min(end + 1, total)):
writer.add_page(reader.pages[idx])
out_buf = io.BytesIO()
writer.write_stream(out_buf)
label = f"{filename_base}_p{start+1}-{min(end,total-1)+1}.pdf"
results.append((label, out_buf.getvalue()))
return results
def extract_pages(
data: bytes,
pages: List[int],
) -> bytes:
"""
Extrai páginas específicas de um PDF e retorna um novo PDF
contendo apenas essas páginas (na ordem fornecida).
Args:
data: Conteúdo do PDF.
pages: Lista de índices de página (0-indexado).
Retorna bytes do novo PDF.
"""
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter()
writer.metadata = reader.metadata
for idx in pages:
if idx < len(reader.pages):
writer.add_page(reader.pages[idx])
out_buf = io.BytesIO()
writer.write_stream(out_buf)
return out_buf.getvalue()
# ── Rotação ─────────────────────────────────────────────────────────
def rotate_pages(
data: bytes,
rotations: List[Tuple[int, int]],
) -> bytes:
"""
Rotaciona páginas específicas de um PDF.
Args:
data: Conteúdo do PDF.
rotations: Lista de (pagina_idx, angulo_em_graus).
Angulo = 90, 180, 270 (sentido anti-horário).
Retorna bytes do PDF rotacionado.
"""
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter()
writer.metadata = reader.metadata
rot_map = {p: a for p, a in rotations}
for i, page in enumerate(reader.pages):
if i in rot_map:
page.rotate(rot_map[i])
writer.add_page(page)
out_buf = io.BytesIO()
writer.write_stream(out_buf)
return out_buf.getvalue()
# ── Extração de texto ──────────────────────────────────────────────
def extract_text(data: bytes) -> str:
"""Extrai texto de todas as páginas do PDF."""
reader = PdfReader(io.BytesIO(data))
pages_text = []
for page in reader.pages:
text = page.extract_text()
if text:
pages_text.append(text)
return "\n".join(pages_text)
# ── Extração de imagens ────────────────────────────────────────────
def extract_images(data: bytes) -> List[Tuple[str, bytes]]:
"""
Extrai todas as imagens de um PDF.
Retorna lista de (nome_extraido, bytes_imagem).
"""
reader = PdfReader(io.BytesIO(data))
images = []
counter = 0
for page_num, page in enumerate(reader.pages):
if "/XObject" not in page.get("/Resources", {}):
continue
xobjects = page["/Resources"]["/XObject"]
for obj_name, xobj in xobjects.items():
if xobj.get("/Subtype") == "/Image":
try:
raw = xobj.get_data()
fmt = xobj.get("/Filter", "")
ext = ".jpg" if "/DCTDecode" in str(fmt) else ".png"
name = f"img_p{page_num+1}_{obj_name.strip('/')}{ext}"
images.append((name, raw))
counter += 1
except Exception:
pass
return images
# ── Preview de páginas (como imagens) ──────────────────────────────
def preview_pages(
data: bytes,
max_pages: int = 10,
dpi: int = 100,
) -> List[Image.Image]:
"""
Gera miniaturas/thumbnails das primeiras páginas de um PDF.
NOTA: pypdf não renderiza páginas; este método retorna uma
imagem em branco com o número da página e o tamanho.
Para preview real, seria necessário pdf2image (poppler).
Esta implementação serve como placeholder — o Streamlit
usará pypdf para mostrar metadados e contagem de páginas,
enquanto a renderização real depende de poppler-utils.
"""
reader = PdfReader(io.BytesIO(data))
previews = []
for i, page in enumerate(reader.pages[:max_pages]):
# Cria uma imagem placeholder com dimensões proporcionais
w = int(page.mediabox.width) if page.mediabox else 595
h = int(page.mediabox.height) if page.mediabox else 842
scale = min(400 / w, 500 / h)
img = Image.new("RGB", (int(w * scale), int(h * scale)), (245, 245, 245))
previews.append(img)
return previews

168
core/pdfa.py Normal file
View File

@@ -0,0 +1,168 @@
"""
Detecção e manipulação de PDF/A.
PDF/A é uma versão padronizada do PDF para arquivamento de longo prazo.
Ele restringe certos recursos (JavaScript, fontes não-embutidas, etc.)
e adiciona metadados XMP específicos.
Este módulo permite:
1. Detectar se um PDF é PDF/A (e qual parte/versão).
2. Converter PDF/A → PDF comum (remove restrições).
3. Otimizar mantendo PDF/A (comprime imagens, limpa objetos).
"""
import io
import re
from typing import Optional
from pypdf import PdfReader, PdfWriter
# ── Detecção ────────────────────────────────────────────────────────
def _check_xmp_pdfa(xml_data: str) -> Optional[str]:
"""Retorna string tipo 'PDF/A-1b' se metadados XMP indicarem PDF/A."""
match = re.search(
r'(pdfaid:part[\'\"]?\s*[:=]\s*[\'\"]?(\d+)[\'\"]?.*?'
r'pdfaid:conformance[\'\"]?\s*[:=]\s*[\'\"]?([A-Za-z])[\'\"]?)',
xml_data,
re.DOTALL | re.IGNORECASE,
)
if match:
part = match.group(2)
conf = match.group(3).upper()
return f"PDF/A-{part}{conf}"
# Fallback mais simples
if "pdfa" in xml_data.lower():
return "PDF/A (indeterminado)"
return None
def _check_xref_pdfa(reader: PdfReader) -> bool:
"""
Verifica presença do dicionário /OutputIntents que é obrigatório
em PDF/A e raro em PDF comum.
"""
try:
catalog = reader.trailer.get("/Root", {})
if not catalog:
return False
output_intents = catalog.get("/OutputIntents", None)
return output_intents is not None and len(output_intents) > 0
except Exception:
return False
def detect_pdfa(data: bytes) -> dict:
"""
Detecta se o PDF é PDF/A e retorna detalhes.
Retorna:
- is_pdfa: bool
- level: str ou None (ex: "PDF/A-2b")
- method: str ("metadata", "output_intent" ou "none")
- confidence: float (0-1)
"""
result = {
"is_pdfa": False,
"level": None,
"method": "none",
"confidence": 0.0,
}
try:
reader = PdfReader(io.BytesIO(data))
# 1. Verifica metadados XMP
if reader.metadata and reader.metadata.raw_data:
raw = str(reader.metadata.raw_data)
level = _check_xmp_pdfa(raw)
if level:
result["is_pdfa"] = True
result["level"] = level
result["method"] = "metadata"
result["confidence"] = 0.9
return result
# 2. Verifica OutputIntents
if _check_xref_pdfa(reader):
result["is_pdfa"] = True
result["level"] = "PDF/A (OutputIntent)"
result["method"] = "output_intent"
result["confidence"] = 0.7
except Exception:
pass
return result
# ── Conversão PDF/A → PDF comum ─────────────────────────────────────
def strip_pdfa(data: bytes) -> bytes:
"""
Remove metadados PDF/A e restrições de arquivamento,
gerando um PDF comum padrão.
O que é removido:
- Metadados XMP com declaração PDF/A
- Dicionário /OutputIntents
- /PieceInfo (se presente)
- Restrições de /Legal e /Copyright
O conteúdo visual é preservado integralmente.
"""
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter()
writer.metadata = reader.metadata
# Copia todas as páginas
for page in reader.pages:
writer.add_page(page)
# Remove XMP metadata que declara PDF/A
if writer.metadata:
# Substitui por metadados limpos (sem PDF/A)
clean_meta = {}
for k, v in reader.metadata.raw_items():
if not any(x in str(k).lower() for x in ["pdfaid", "xmp", "adobe:nsmeta"]):
clean_meta[k] = v
writer.metadata = type(reader.metadata)(clean_meta)
out_buf = io.BytesIO()
writer.write_stream(out_buf)
return out_buf.getvalue()
# ── Otimização mantendo PDF/A ───────────────────────────────────────
def compress_pdfa(data: bytes, quality: int = 60, max_dim: int = 2000) -> bytes:
"""
Comprime imagens internas mantendo a estrutura PDF/A.
Usa a mesma lógica de compress.compress_pdf mas preserva
metadados XMP e OutputIntents.
"""
from .compress import _downsample_image
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter()
writer.metadata = reader.metadata
for page in reader.pages:
if "/XObject" in page.get("/Resources", {}):
xobjects = page["/Resources"]["/XObject"]
for obj_name in list(xobjects.keys()):
xobj = xobjects[obj_name]
if xobj.get("/Subtype") == "/Image":
try:
raw = xobj.get_data()
if raw:
compressed = _downsample_image(raw, quality, max_dim)
xobj._data = compressed
except Exception:
pass
writer.add_page(page)
out_buf = io.BytesIO()
writer.write_stream(out_buf)
return out_buf.getvalue()

78
core/validators.py Normal file
View File

@@ -0,0 +1,78 @@
"""
Validação de arquivos PDF recebidos.
"""
import io
import struct
from pathlib import Path
from typing import Union
from pypdf import PdfReader
PDF_MAGIC = b"%PDF"
def is_pdf_stream(data: bytes) -> bool:
"""Verifica se o buffer começa com a magic number de PDF."""
return data[:4] == PDF_MAGIC
def is_pdf_file(path: Union[str, Path]) -> bool:
"""Verifica se o arquivo é um PDF válido."""
path = Path(path)
if not path.exists() or not path.is_file():
return False
with open(path, "rb") as f:
header = f.read(4)
return header == PDF_MAGIC
def validate_pdf(data: bytes, filename: str = "") -> dict:
"""
Valida um PDF em memória. Retorna dict com:
- valid: bool
- pages: int (se válido)
- error: str (se inválido)
- is_pdfa: bool (provável PDF/A)
- file_size_mb: float
"""
result = {
"valid": False,
"pages": 0,
"error": "",
"is_pdfa": False,
"file_size_mb": round(len(data) / (1024 * 1024), 2),
"filename": filename,
}
if not is_pdf_stream(data):
result["error"] = "Arquivo não é um PDF (magic number inválido)"
return result
try:
reader = PdfReader(io.BytesIO(data))
result["pages"] = len(reader.pages)
result["valid"] = True
# Detecção básica de PDF/A via metadados XMP
if reader.metadata:
xmp = str(reader.metadata.raw_data or "")
if "/pdfaid:part" in xmp.lower() or "pdfa" in xmp.lower():
result["is_pdfa"] = True
except Exception as e:
result["error"] = f"Erro ao ler PDF: {str(e)}"
return result
def assert_safe_filename(name: str) -> str:
"""Remove caracteres perigosos de nomes de arquivo."""
import re
safe = re.sub(r'[^\w\-. ]', "_", name)
if not safe:
safe = "documento"
if not safe.lower().endswith(".pdf"):
safe += ".pdf"
return safe

240
docs/API.md Normal file
View File

@@ -0,0 +1,240 @@
# API do pdf-toolkit
Documentação interativa disponível em:
- **Swagger UI**: http://localhost:8555/docs
- **Redoc**: http://localhost:8555/redoc
---
## Base URL
```
http://localhost:8555
```
---
## Endpoints
### `GET /`
Raiz informativa.
**Resposta:**
```json
{
"app": "pdf-toolkit API",
"version": "1.0.0",
"docs": "/docs",
"redoc": "/redoc"
}
```
---
### `GET /health`
Health check.
**Resposta:**
```json
{"status": "ok", "version": "1.0.0"}
```
---
### `POST /inspect`
Inspeciona metadados de um PDF.
**Parâmetros:**
| Nome | Tipo | Obrigatório |
|---|---|---|
| `file` | arquivo PDF | ✅ |
**Resposta:**
```json
{
"valid": true,
"pages": 42,
"error": "",
"is_pdfa": false,
"file_size_mb": 2.34,
"filename": "relatorio.pdf",
"level": null,
"method": "none",
"confidence": 0.0
}
```
---
### `POST /compress`
Comprime um PDF re-amostrando imagens.
**Parâmetros:**
| Nome | Tipo | Padrão | Obrigatório |
|---|---|---|---|
| `file` | arquivo | — | ✅ |
| `preset` | string | `"ebook"` | ❌ |
**Presets válidos:** `screen`, `ebook`, `printer`, `prepress`
**Resposta:** PDF comprimido (download)
---
### `POST /pdfa-to-regular`
Remove restrições PDF/A gerando PDF comum.
**Parâmetros:**
| Nome | Tipo | Obrigatório |
|---|---|---|
| `file` | arquivo PDF | ✅ |
**Resposta:** PDF comum (download)
---
### `POST /compress-pdfa`
Comprime imagens mantendo a estrutura PDF/A.
**Parâmetros:**
| Nome | Tipo | Padrão | Obrigatório |
|---|---|---|---|
| `file` | arquivo | — | ✅ |
| `quality` | int (10-100) | 60 | ❌ |
**Resposta:** PDF otimizado (download)
---
### `POST /merge`
Junta múltiplos PDFs em um único arquivo.
**Parâmetros:**
| Nome | Tipo | Obrigatório |
|---|---|---|
| `files` | lista de arquivos | ✅ (mín: 2) |
**Resposta:** PDF mesclado (download)
---
### `POST /split`
Divide um PDF em múltiplos arquivos por intervalos de páginas.
**Parâmetros:**
| Nome | Tipo | Obrigatório |
|---|---|---|
| `file` | arquivo PDF | ✅ |
| `ranges` | string | ✅ |
**Exemplo de `ranges`:** `"1-3,5,7-9"` (1-indexado)
**Resposta:** ZIP com os PDFs extraídos
---
### `POST /extract-pages`
Extrai páginas específicas em ordem para um único PDF.
**Parâmetros:**
| Nome | Tipo | Obrigatório |
|---|---|---|
| `file` | arquivo PDF | ✅ |
| `pages` | string | ✅ |
**Exemplo de `pages`:** `"1,3,5"` (1-indexado)
**Resposta:** PDF com as páginas selecionadas (download)
---
### `POST /rotate`
Rotaciona páginas de um PDF.
**Parâmetros:**
| Nome | Tipo | Padrão | Obrigatório |
|---|---|---|---|
| `file` | arquivo | — | ✅ |
| `pages` | string | — | ✅ |
| `angle` | int | 90 | ❌ |
**Exemplo de `pages`:** `"1,3,5"` ou `"todas"`
**Resposta:** PDF rotacionado (download)
---
### `POST /extract-text`
Extrai texto de todas as páginas do PDF.
**Parâmetros:**
| Nome | Tipo | Obrigatório |
|---|---|---|
| `file` | arquivo PDF | ✅ |
**Resposta:** arquivo `.txt` (download)
---
### `POST /extract-images`
Extrai todas as imagens embutidas no PDF.
**Parâmetros:**
| Nome | Tipo | Obrigatório |
|---|---|---|
| `file` | arquivo PDF | ✅ |
**Resposta:** ZIP com as imagens extraídas
---
## Exemplos com curl
### Comprimir
```bash
curl -X POST http://localhost:8555/compress \
-F "file=@documento.pdf" \
-F "preset=ebook" \
-o comprimido.pdf
```
### Merge
```bash
curl -X POST http://localhost:8555/merge \
-F "files=@parte1.pdf" \
-F "files=@parte2.pdf" \
-o merged.pdf
```
### Split
```bash
curl -X POST http://localhost:8555/split \
-F "file=@documento.pdf" \
-F "ranges=1-3,5,7-9" \
-o split.zip
```
### Inspect
```bash
curl -s -X POST http://localhost:8555/inspect \
-F "file=@documento.pdf" | jq .
```
### Extract text
```bash
curl -X POST http://localhost:8555/extract-text \
-F "file=@documento.pdf" \
-o texto.txt
```

149
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,149 @@
# Arquitetura do pdf-toolkit
## Visão geral
O pdf-toolkit é um sistema de duas faces: **GUI (Streamlit)** e **API REST (FastAPI)**, ambas compartilhando o mesmo núcleo (`core/`) e configurações (`config.py`).
```
┌─────────────────────┐ ┌──────────────────────┐
│ Streamlit (app.py) │ │ FastAPI (api.py) │
│ ─ Porta 8550 │ │ ─ Porta 8555 │
│ ─ Upload drag-drop │ │ ─ Swagger /docs │
│ ─ Preview visual │ │ ─ Redoc /redoc │
└────────┬────────────┘ └──────────┬───────────┘
│ │
└──────────┬──────────────────┘
│
┌──────────▼───────────┐
│ core/ (engine) │
│ ┌─────────────────┐ │
│ │ engine.py │ │← Orquestrador (facade)
│ │ validators.py │ │← Valida entradas
│ │ compress.py │ │← Compressão/redução
│ │ pdfa.py │ │← PDF/A detection & strip
│ │ operations.py │ │← Merge, split, rotate...
│ └─────────────────┘ │
└──────────────────────┘
```
### Fluxo de dados
1. **Entrada**: bytes do PDF via upload (Streamlit) ou HTTP multipart (API)
2. **Validação**: `validators.validate_pdf()` checa magic number, lê metadados
3. **Detecção PDF/A**: `pdfa.detect_pdfa()` verifica XMP metadata e OutputIntents
4. **Transformação**: operação específica (compressão, split, etc.)
5. **Saída**: bytes transformados → download (Streamlit) ou Response (API)
---
## Módulo `core/engine.py`
`PDFEngine` é uma classe de fachada que unifica todos os submódulos. Cada método:
- Recebe `bytes` (ou lista de bytes)
- Retorna `bytes` (ou metadados dict)
- Propaga exceções para o caller (Streamlit ou FastAPI)
```python
engine = PDFEngine()
info = engine.inspect(data, "doc.pdf")
comprimido = engine.compress(data, preset="ebook")
```
---
## Módulo `core/compress.py`
### Estratégia de compressão
1. Para cada página, varre `/Resources/XObject` em busca de `/Subtype == /Image`
2. Extrai dados brutos da imagem
3. Redimensiona proporcionalmente se exceder `max_dim` (LANCZOS)
4. Re-comprime em JPEG com `quality` definida
5. Substitui `_data` do objeto XObject
6. Re-escreve o PDF com `PdfWriter.write_stream()` (já faz compressão de streams)
### Por que não usamos linearização?
`pypdf` não suporta linearização true (fast web view). A opção `linearize_pdf()` apenas re-escreve o PDF, o que já melhora a estrutura. Para linearização real, seria necessário Ghostscript ou qpdf.
---
## Módulo `core/pdfa.py`
### Detecção de PDF/A
Dois métodos complementares:
1. **Metadados XMP**: busca padrão `pdfaid:part` + `pdfaid:conformance` via regex
2. **OutputIntents**: verifica `/OutputIntents` no dicionário raiz do PDF (obrigatório em PDF/A)
### Conversão PDF/A → PDF comum
- Remove metadados XMP que declaram PDF/A
- Remove dicionário `/OutputIntents`
- Preserva conteúdo visual integralmente
- Mantém metadados não-PDF/A (autor, título, etc.)
---
## Módulo `core/operations.py`
Operações independentes que não dependem entre si:
| Função | Descrição |
|---|---|
| `merge_pdfs()` | Concatena PDFs em ordem |
| `split_pdf()` | Extrai intervalos → múltiplos PDFs |
| `extract_pages()` | Extrai páginas específicas → 1 PDF |
| `rotate_pages()` | Rotaciona páginas (90/180/270) |
| `extract_text()` | Extrai texto de todas as páginas |
| `extract_images()` | Extrai imagens embutidas |
---
## API (FastAPI)
A API usa `File` (multipart) para upload e `Response` para download.
### Validação automática via FastAPI:
- Tipo dos parâmetros
- Limite de tamanho (`_read_file()`)
- Presets válidos
### Documentação: `/docs` (Swagger) e `/redoc` (Redoc) são gerados automaticamente.
---
## Streamlit (GUI)
A GUI organiza-se em:
1. **Sidebar**: upload + seleção de operação + parâmetros + botão executar
2. **Área principal**: preview dos arquivos ou resultados
### Estados:
- **Antes de executar**: exibe metadados dos PDFs carregados
- **Executando**: barra de progresso com descrição
- **Resultado**: download individual ou ZIP com múltiplos arquivos
---
## Dependências
| Pacote | Uso |
|---|---|
| `streamlit` | Interface web |
| `fastapi` + `uvicorn` | API REST |
| `pypdf` | Leitura/escrita de PDF |
| `Pillow` | Processamento de imagens |
| `python-multipart` | Upload na API |
---
## Próximas melhorias possíveis
- [ ] Integração com Ghostscript para compressão mais agressiva
- [ ] Preview visual de páginas via `pdf2image` + `poppler-utils`
- [ ] Suporte a PDFs criptografados (senha)
- [ ] OCR em PDFs scaneados (Tesseract)
- [ ] Concorrência em lote com `asyncio`/`ThreadPoolExecutor`
- [ ] Autenticação na API (API keys)
- [ ] Dockerfile para deploy

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
streamlit>=1.30
fastapi>=0.100
uvicorn[standard]>=0.20
pypdf>=5
Pillow>=10
python-multipart>=0.0.6

8
scripts/run-api.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Inicia a API REST do pdf-toolkit
set -e
cd "$(dirname "$0")/.."
echo "🔌 pdf-toolkit — API"
echo " Swagger: http://localhost:8555/docs"
echo " Redoc: http://localhost:8555/redoc"
exec uvicorn api:app --host 0.0.0.0 --port 8555 --reload

7
scripts/run-web.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Inicia o pdf-toolkit via Streamlit
set -e
cd "$(dirname "$0")/.."
echo "🌐 pdf-toolkit — Streamlit"
echo " Acesse: http://localhost:8550"
exec streamlit run app.py --server.port 8550

143
tests/test_core.py Normal file
View File

@@ -0,0 +1,143 @@
"""
Testes unitários do core do pdf-toolkit.
"""
import io
from pathlib import Path
import pytest
from PIL import Image
from core.validators import validate_pdf, is_pdf_stream, is_pdf_file
from core.engine import PDFEngine
# ── Helpers ─────────────────────────────────────────────────────────
def make_dummy_pdf() -> bytes:
"""Gera um PDF mínimo válido para testes."""
return (
b"%PDF-1.4\n"
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]\n"
b" /Resources << /Font << /F1 4 0 R >> >>\n"
b" /Contents 5 0 R\n>>\nendobj\n"
b"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"
b"5 0 obj\n<< /Length 44 >>\nstream\nBT /F1 12 Tf 100 700 Td (Hello) Tj ET\nendstream\nendobj\n"
b"xref\n0 6\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000266 00000 n \n0000000340 00000 n \n"
b"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n462\n%%EOF\n"
)
engine = PDFEngine()
# ── Tests ───────────────────────────────────────────────────────────
class TestCompress:
def test_compress_ebook(self):
pdf = make_dummy_pdf()
result = engine.compress(pdf, preset="ebook")
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
# PDF mínimo não tem imagens, então tamanho fica similar
# mas a estrutura deve ser válida
info = engine.inspect(result)
assert info["valid"] is True
def test_compress_screen(self):
pdf = make_dummy_pdf()
result = engine.compress(pdf, preset="screen")
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
def test_compress_invalid_preset(self):
pdf = make_dummy_pdf()
# Preset inválido usa o padrão (ebook) sem levantar erro
# O comportamento é fallback seguro
result = engine.compress(pdf, preset="invalid")
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
class TestInspect:
def test_inspect_valid_pdf(self):
pdf = make_dummy_pdf()
info = engine.inspect(pdf, "test.pdf")
assert info["valid"] is True
assert info["pages"] == 1
assert info["file_size_mb"] >= 0
assert info["filename"] == "test.pdf"
def test_inspect_invalid_data(self):
info = engine.inspect(b"not a pdf", "fake.pdf")
assert info["valid"] is False
assert info["error"] != ""
class TestPdfa:
def test_detect_pdfa_returns_dict(self):
pdf = make_dummy_pdf()
result = engine.detect_pdfa(pdf)
assert isinstance(result, dict)
assert "is_pdfa" in result
assert "level" in result
assert "method" in result
def test_pdfa_to_regular_noop_on_common(self):
pdf = make_dummy_pdf()
result = engine.pdfa_to_regular(pdf)
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
def test_compress_pdfa_preserves_structure(self):
pdf = make_dummy_pdf()
result = engine.compress_pdfa(pdf, quality=50)
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
class TestOperations:
def test_merge_single(self):
pdf = make_dummy_pdf()
result = engine.merge([(pdf, "a.pdf")])
assert result[:4] == b"%PDF"
info = engine.inspect(result)
assert info["pages"] == 1
def test_merge_multiple(self):
pdf = make_dummy_pdf()
result = engine.merge([(pdf, "a.pdf"), (pdf, "b.pdf")])
info = engine.inspect(result)
assert info["pages"] == 2
def test_split(self):
pdf = make_dummy_pdf()
results = engine.split(pdf, [(0, 0)], "teste")
assert len(results) == 1
nome, dados = results[0]
assert "teste" in nome
assert dados[:4] == b"%PDF"
def test_extract_pages(self):
pdf = make_dummy_pdf()
result = engine.extract(pdf, [0])
assert result[:4] == b"%PDF"
info = engine.inspect(result)
assert info["pages"] == 1
def test_rotate(self):
pdf = make_dummy_pdf()
result = engine.rotate(pdf, [(0, 90)])
assert result[:4] == b"%PDF"
def test_extract_text(self):
pdf = make_dummy_pdf()
text = engine.text(pdf)
assert "Hello" in text
def test_extract_images_empty(self):
pdf = make_dummy_pdf()
imgs = engine.images(pdf)
assert imgs == []