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:
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)
|
||||
Reference in New Issue
Block a user