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:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
122
README.md
Normal 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
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)
|
||||||
288
app.py
Normal file
288
app.py
Normal 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
38
config.py
Normal 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
1
core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# pdf-toolkit core — motor de transformação de PDFs
|
||||||
134
core/compress.py
Normal file
134
core/compress.py
Normal 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
99
core/engine.py
Normal 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
199
core/operations.py
Normal 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
168
core/pdfa.py
Normal 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
78
core/validators.py
Normal 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
240
docs/API.md
Normal 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
149
docs/ARCHITECTURE.md
Normal 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
6
requirements.txt
Normal 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
8
scripts/run-api.sh
Executable 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
7
scripts/run-web.sh
Executable 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
143
tests/test_core.py
Normal 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 == []
|
||||||
Reference in New Issue
Block a user