commit 84d4a1eab577230127e4e68798b3c588aeb18128 Author: CM Consultor Date: Sun Aug 9 02:15:22 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe0fe8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.env +.env.local +venv/ +.venv/ +tmp/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..00ae32e --- /dev/null +++ b/README.md @@ -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 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 \ No newline at end of file diff --git a/api.py b/api.py new file mode 100644 index 0000000..7c3b1ce --- /dev/null +++ b/api.py @@ -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) \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..0467341 --- /dev/null +++ b/app.py @@ -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) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..d0ddbe1 --- /dev/null +++ b/config.py @@ -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 \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..718c2b1 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# pdf-toolkit core — motor de transformação de PDFs \ No newline at end of file diff --git a/core/compress.py b/core/compress.py new file mode 100644 index 0000000..d4799ce --- /dev/null +++ b/core/compress.py @@ -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() \ No newline at end of file diff --git a/core/engine.py b/core/engine.py new file mode 100644 index 0000000..6812480 --- /dev/null +++ b/core/engine.py @@ -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) \ No newline at end of file diff --git a/core/operations.py b/core/operations.py new file mode 100644 index 0000000..21e15d6 --- /dev/null +++ b/core/operations.py @@ -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 \ No newline at end of file diff --git a/core/pdfa.py b/core/pdfa.py new file mode 100644 index 0000000..13e59c9 --- /dev/null +++ b/core/pdfa.py @@ -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() \ No newline at end of file diff --git a/core/validators.py b/core/validators.py new file mode 100644 index 0000000..db000f1 --- /dev/null +++ b/core/validators.py @@ -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 \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..0730366 --- /dev/null +++ b/docs/API.md @@ -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 +``` \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d3bc4ba --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1828222 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +streamlit>=1.30 +fastapi>=0.100 +uvicorn[standard]>=0.20 +pypdf>=5 +Pillow>=10 +python-multipart>=0.0.6 \ No newline at end of file diff --git a/scripts/run-api.sh b/scripts/run-api.sh new file mode 100755 index 0000000..38def6c --- /dev/null +++ b/scripts/run-api.sh @@ -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 \ No newline at end of file diff --git a/scripts/run-web.sh b/scripts/run-web.sh new file mode 100755 index 0000000..4ee7ca7 --- /dev/null +++ b/scripts/run-web.sh @@ -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 \ No newline at end of file diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..92fd50e --- /dev/null +++ b/tests/test_core.py @@ -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 == [] \ No newline at end of file