fix: compressão real p/ stream DCTDecode + auth c/ chave vazia

- Substitui xobj._data por page.images[].replace() em compress.py e pdfa.py
- Adiciona compress_with_stats() com feedback honesto na UI
- Trata PDFTOOLKIT_API_KEY vazia como modo dev (auth desabilitado)
- Atualiza engine.py e app.py para exibir redução real ou 'já otimizado'
This commit is contained in:
CM Consultor
2026-08-09 05:01:16 +00:00
parent 4496e9cb55
commit e97adca143
5 changed files with 239 additions and 111 deletions

16
app.py
View File

@@ -148,13 +148,16 @@ try:
if operation == "📦 Comprimir PDF": if operation == "📦 Comprimir PDF":
progress.progress(0.1, "Comprimindo...") progress.progress(0.1, "Comprimindo...")
for i, (fname, fobj) in enumerate(files_data.items()): for i, (fname, fobj) in enumerate(files_data.items()):
result_data = engine.compress(fobj["data"], preset=extra_opts["preset"]) result_data, stats = engine.compress_with_stats(fobj["data"], preset=extra_opts["preset"])
orig_mb = fobj["info"]["file_size_mb"] orig_mb = fobj["info"]["file_size_mb"]
new_mb = round(len(result_data) / (1024 * 1024), 2) new_mb = round(len(result_data) / (1024 * 1024), 2)
reduction = round((1 - (new_mb / orig_mb)) * 100, 1) if orig_mb > 0 else 0 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)) results.append((fname.replace(".pdf", f"_comprimido.pdf"), result_data))
progress.progress((i + 1) / len(files_data), if stats["reduced"]:
f"{fname}: {orig_mb}MB → {new_mb}MB ({reduction}% redução)") msg = f"{fname}: {orig_mb}MB → {new_mb}MB ({reduction}% redução)"
else:
msg = f"{fname}: já otimizado — {len(result_data)} bytes (sem redução adicional)" if stats["images_replaced"] == 0 else f"{fname}: {orig_mb}MB → {new_mb}MB (compressão não reduziu o tamanho, original preservado)"
progress.progress((i + 1) / len(files_data), msg)
elif operation == "📄 PDF/A → PDF comum": elif operation == "📄 PDF/A → PDF comum":
progress.progress(0.1, "Convertendo PDF/A...") progress.progress(0.1, "Convertendo PDF/A...")
@@ -174,12 +177,13 @@ try:
if fobj["info"].get("is_pdfa"): if fobj["info"].get("is_pdfa"):
result_data = engine.compress_pdfa(fobj["data"], quality=q) result_data = engine.compress_pdfa(fobj["data"], quality=q)
else: else:
# Se não for PDF/A, aplica compressão normal # Se não for PDF/A, aplica compressão normal com stats
result_data = engine.compress(fobj["data"], preset=extra_opts["preset"]) result_data, _ = engine.compress_with_stats(fobj["data"], preset=extra_opts["preset"])
orig_mb = fobj["info"]["file_size_mb"] orig_mb = fobj["info"]["file_size_mb"]
new_mb = round(len(result_data) / (1024 * 1024), 2) new_mb = round(len(result_data) / (1024 * 1024), 2)
results.append((fname.replace(".pdf", f"_otimizado.pdf"), result_data)) results.append((fname.replace(".pdf", f"_otimizado.pdf"), result_data))
progress.progress((i + 1) / len(files_data)) progress.progress((i + 1) / len(files_data),
f"{fname}: {orig_mb}MB → {new_mb}MB")
elif operation == "🔗 Merge (juntar PDFs)": elif operation == "🔗 Merge (juntar PDFs)":
progress.progress(0.3, "Mesclando PDFs...") progress.progress(0.3, "Mesclando PDFs...")

View File

@@ -2,8 +2,8 @@
Autenticação via API Key para a API REST. Autenticação via API Key para a API REST.
A chave é lida da variável de ambiente PDFTOOLKIT_API_KEY. A chave é lida da variável de ambiente PDFTOOLKIT_API_KEY.
Se a variável não estiver definida, a autenticação fica desabilitada Se a variável não estiver definida **ou estiver vazia**, a
(útil para desenvolvimento local). autenticação fica desabilitada (útil para desenvolvimento local).
Uso: Uso:
export PDFTOOLKIT_API_KEY="sua-chave-secreta-aqui" export PDFTOOLKIT_API_KEY="sua-chave-secreta-aqui"
@@ -15,8 +15,9 @@ from fastapi.security import APIKeyHeader
API_KEY_ENV = "PDFTOOLKIT_API_KEY" API_KEY_ENV = "PDFTOOLKIT_API_KEY"
# Lê a chave do ambiente (None se não configurada) # Lê a chave do ambiente (None se não configurada ou vazia)
_API_KEY = os.environ.get(API_KEY_ENV, None) _API_KEY_CANDIDATE = os.environ.get(API_KEY_ENV, None)
_API_KEY = _API_KEY_CANDIDATE if _API_KEY_CANDIDATE else None
# Schema de header: X-API-Key # Schema de header: X-API-Key
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
@@ -26,11 +27,12 @@ def verify_api_key(api_key: str = Security(api_key_header)) -> str:
""" """
Dependência FastAPI que valida a API Key. Dependência FastAPI que valida a API Key.
- Se PDFTOOLKIT_API_KEY não foi definida no ambiente → permite acesso livre - Se PDFTOOLKIT_API_KEY não foi definida ou está vazia → permite
(modo desenvolvimento). acesso livre (modo desenvolvimento).
- Se foi definida → exige que o header X-API-Key corresponda exatamente. - Se foi definida → exige que o header X-API-Key corresponda
exatamente.
Retorna a própria chave se válida, ou levanta 401. Retorna a própria chave se válida, ou levanta 401/403.
""" """
# Modo desenvolvimento: sem chave configurada # Modo desenvolvimento: sem chave configurada
if _API_KEY is None: if _API_KEY is None:

View File

@@ -1,20 +1,64 @@
""" """
Estratégias de compressão e otimização de PDF. Estratégias de compressão e otimização de PDF.
Usa pypdf + Pillow para re-amostragem de imagens e remoção de Usa pypdf (API oficial de ImageFile.replace) + Pillow para
objetos redundantes. Mantém a estrutura original do PDF (inclusive re-amostragem de imagens. A abordagem antiga de gravar em
PDF/A) quando possível. `xobj._data` quebrava o /Filter do stream e falhava em silêncio
na maioria dos PDFs (FlateDecode / pixels crus).
""" """
from __future__ import annotations
import io import io
from typing import Optional import logging
from typing import Optional, Tuple
from PIL import Image from PIL import Image
from pypdf import PdfReader, PdfWriter from pypdf import PdfReader, PdfWriter
from pypdf.constants import ImageAttributes as IA
from config import QUALITY_PRESETS from config import QUALITY_PRESETS
logger = logging.getLogger(__name__)
def _prepare_image(
img: Image.Image,
quality: int = 60,
max_dim: int = 2000,
) -> Tuple[Image.Image, dict]:
"""
Normaliza modo de cor e redimensiona para o preset.
Retorna (imagem_pronta, kwargs para Image.save / replace).
"""
# JPEG (via replace→PDF) não aceita alpha/CMYK/paleta de forma confiável
if img.mode in ("RGBA", "LA"):
background = Image.new("RGB", img.size, (255, 255, 255))
alpha = img.split()[-1]
background.paste(img.convert("RGBA"), mask=alpha)
img = background
elif img.mode == "P":
img = img.convert("RGBA")
if "A" in img.getbands():
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1])
img = background
else:
img = img.convert("RGB")
elif img.mode == "CMYK":
img = img.convert("RGB")
elif img.mode not in ("RGB", "L"):
img = img.convert("RGB")
w, h = img.size
if max(w, h) > max_dim:
ratio = max_dim / max(w, h)
new_size = (max(1, int(w * ratio)), max(1, int(h * ratio)))
img = img.resize(new_size, Image.LANCZOS)
save_kwargs = {"quality": quality, "optimize": True}
return img, save_kwargs
def _downsample_image( def _downsample_image(
img_bytes: bytes, img_bytes: bytes,
@@ -23,34 +67,23 @@ def _downsample_image(
fmt: str = "JPEG", fmt: str = "JPEG",
) -> bytes: ) -> bytes:
""" """
Redimensiona e re-comprime uma imagem. Redimensiona e re-comprime bytes de imagem (helper legado / pdfa).
- quality: 1-100 (JPEG)
- max_dim: lado máximo em px Aceita apenas containers que o Pillow abra (JPEG/PNG/...).
- fmt: "JPEG" ou "PNG" Pixels crus de streams FlateDecode NÃO funcionam aqui — use
compress_pdf(), que passa por page.images (PIL Image já montada).
""" """
img = Image.open(io.BytesIO(img_bytes)) img = Image.open(io.BytesIO(img_bytes))
img, save_kwargs = _prepare_image(img, quality=quality, max_dim=max_dim)
# 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() buf = io.BytesIO()
if fmt == "JPEG": out_fmt = "JPEG" if fmt.upper() == "JPEG" else "PNG"
img.save(buf, format="JPEG", quality=quality, optimize=True) if out_fmt == "JPEG" and img.mode not in ("RGB", "L"):
img = img.convert("RGB")
if out_fmt == "JPEG":
img.save(buf, format="JPEG", **save_kwargs)
else: else:
img.save(buf, format="PNG", optimize=True) img.save(buf, format="PNG", optimize=True)
return buf.getvalue() return buf.getvalue()
@@ -60,75 +93,148 @@ def compress_pdf(
remove_unused: bool = True, remove_unused: bool = True,
) -> bytes: ) -> bytes:
""" """
Comprime um PDF re-amostrando imagens e otimizando estrutura. Comprime um PDF re-amostrando imagens embutidas.
Args: Args:
data: Conteúdo do PDF original. data: Conteúdo do PDF original.
preset: Perfil de qualidade ("screen", "ebook", "printer", "prepress"). preset: Perfil de qualidade ("screen", "ebook", "printer", "prepress").
remove_unused: Remove objetos não referenciados. remove_unused: Tenta deduplicar objetos idênticos no writer.
Retorna bytes do PDF comprimido. Retorna bytes do PDF (pode ser maior em PDFs só-texto; o engine
decide se devolve o original nesse caso).
""" """
opts = QUALITY_PRESETS.get(preset, QUALITY_PRESETS["ebook"]) opts = QUALITY_PRESETS.get(preset, QUALITY_PRESETS["ebook"])
quality = opts["quality"] quality = opts["quality"]
max_dim = opts["max_dim"] max_dim = opts["max_dim"]
reader = PdfReader(io.BytesIO(data)) reader = PdfReader(io.BytesIO(data))
writer = PdfWriter() # clone_from é obrigatório para ImageFile.replace() funcionar
writer = PdfWriter(clone_from=reader)
# Copia metadados replaced = 0
writer.metadata = reader.metadata errors = 0
# Registra fontes e objetos que usamos (para limpeza) for page in writer.pages:
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: try:
raw = xobj.get_data() images = list(page.images)
if raw: except Exception as exc:
# Determina formato — PNG se tiver transparência logger.debug("Falha ao listar imagens da página: %s", exc)
fmt = "PNG" if xobj.get("/ColorSpace") == "/DeviceCMYK" else "JPEG" continue
compressed = _downsample_image(raw, quality, max_dim, fmt)
xobj._data = compressed for img_file in images:
xobj[IA.WIDTH], xobj[IA.HEIGHT] = Image.open( # Imagens inline não têm indirect_reference — replace() recusa
io.BytesIO(compressed) if getattr(img_file, "indirect_reference", None) is None:
).size continue
try:
pil = img_file.image
if pil is None:
continue
prepared, save_kwargs = _prepare_image(
pil, quality=quality, max_dim=max_dim
)
img_file.replace(prepared, **save_kwargs)
replaced += 1
except Exception as exc:
errors += 1
logger.debug(
"Falha ao recomprimir imagem %s: %s",
getattr(img_file, "name", "?"),
exc,
)
if remove_unused:
try:
writer.compress_identical_objects()
except Exception: except Exception:
# Se falhar na recompressão, mantém original
pass pass
# Adiciona página modificada
writer.add_page(page)
# Escreve com compressão de streams
out_buf = io.BytesIO() out_buf = io.BytesIO()
writer.write_stream(out_buf) writer.write(out_buf)
result = out_buf.getvalue()
return out_buf.getvalue() logger.info(
"compress_pdf preset=%s images_replaced=%d errors=%d "
"in=%d out=%d",
preset,
replaced,
errors,
len(data),
len(result),
)
return result
def compress_pdf_with_stats(
data: bytes,
preset: str = "ebook",
remove_unused: bool = True,
) -> Tuple[bytes, dict]:
"""
Igual a compress_pdf, mas devolve estatísticas úteis para UI/API.
"""
opts = QUALITY_PRESETS.get(preset, QUALITY_PRESETS["ebook"])
quality = opts["quality"]
max_dim = opts["max_dim"]
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter(clone_from=reader)
replaced = 0
skipped = 0
errors = 0
for page in writer.pages:
try:
images = list(page.images)
except Exception:
continue
for img_file in images:
if getattr(img_file, "indirect_reference", None) is None:
skipped += 1
continue
try:
pil = img_file.image
if pil is None:
skipped += 1
continue
prepared, save_kwargs = _prepare_image(
pil, quality=quality, max_dim=max_dim
)
img_file.replace(prepared, **save_kwargs)
replaced += 1
except Exception:
errors += 1
if remove_unused:
try:
writer.compress_identical_objects()
except Exception:
pass
out_buf = io.BytesIO()
writer.write(out_buf)
result = out_buf.getvalue()
stats = {
"images_replaced": replaced,
"images_skipped": skipped,
"images_errors": errors,
"original_bytes": len(data),
"result_bytes": len(result),
"reduced": len(result) < len(data),
"preset": preset,
}
return result, stats
def linearize_pdf(data: bytes) -> bytes: def linearize_pdf(data: bytes) -> bytes:
""" """
Re-escreve o PDF de forma linearizada (otimizada para web). Re-escreve o PDF (clone limpo). Não é linearização real de web-fast,
Usa pypdf para reestruturar. mas reestrutura o arquivo via pypdf.
""" """
reader = PdfReader(io.BytesIO(data)) reader = PdfReader(io.BytesIO(data))
writer = PdfWriter() writer = PdfWriter(clone_from=reader)
writer.metadata = reader.metadata
for page in reader.pages:
writer.add_page(page)
out_buf = io.BytesIO() out_buf = io.BytesIO()
writer.write_stream(out_buf) writer.write(out_buf)
return out_buf.getvalue() return out_buf.getvalue()

View File

@@ -9,7 +9,7 @@ import io
from typing import List, Tuple from typing import List, Tuple
from .validators import validate_pdf, assert_safe_filename from .validators import validate_pdf, assert_safe_filename
from .compress import compress_pdf from .compress import compress_pdf, compress_pdf_with_stats
from .pdfa import detect_pdfa, strip_pdfa, compress_pdfa from .pdfa import detect_pdfa, strip_pdfa, compress_pdfa
from .operations import merge_pdfs, split_pdf, extract_pages, rotate_pages from .operations import merge_pdfs, split_pdf, extract_pages, rotate_pages
from .operations import extract_text, extract_images, preview_pages from .operations import extract_text, extract_images, preview_pages
@@ -38,8 +38,18 @@ class PDFEngine:
ou que não possuem imagens adequadas para reamostragem. Nesse caso, ou que não possuem imagens adequadas para reamostragem. Nesse caso,
preservamos o original em vez de entregar um arquivo maior. preservamos o original em vez de entregar um arquivo maior.
""" """
result = compress_pdf(data, preset=preset) result, stats = compress_pdf_with_stats(data, preset=preset)
return result if len(result) < len(data) else data if stats["reduced"]:
return result
return data
@staticmethod
def compress_with_stats(data: bytes, preset: str = "ebook") -> Tuple[bytes, dict]:
"""Comprime e retorna (bytes, estatísticas) para feedback na UI."""
result, stats = compress_pdf_with_stats(data, preset=preset)
if stats["reduced"]:
return result, stats
return data, stats
@staticmethod @staticmethod
def pdfa_to_regular(data: bytes) -> bytes: def pdfa_to_regular(data: bytes) -> bytes:

View File

@@ -139,30 +139,36 @@ def strip_pdfa(data: bytes) -> bytes:
def compress_pdfa(data: bytes, quality: int = 60, max_dim: int = 2000) -> bytes: def compress_pdfa(data: bytes, quality: int = 60, max_dim: int = 2000) -> bytes:
""" """
Comprime imagens internas mantendo a estrutura PDF/A. Comprime imagens internas mantendo a estrutura PDF/A.
Usa a mesma lógica de compress.compress_pdf mas preserva Usa a mesma lógica de compress_pdf (ImageFile.replace)
metadados XMP e OutputIntents. mas preserva metadados XMP e OutputIntents.
""" """
from .compress import _downsample_image from PIL import Image as PILImage
from .compress import _prepare_image
reader = PdfReader(io.BytesIO(data)) reader = PdfReader(io.BytesIO(data))
writer = PdfWriter() writer = PdfWriter(clone_from=reader)
writer.metadata = reader.metadata
for page in reader.pages: replaced = 0
if "/XObject" in page.get("/Resources", {}): for page in writer.pages:
xobjects = page["/Resources"]["/XObject"]
for obj_name in list(xobjects.keys()):
xobj = xobjects[obj_name]
if xobj.get("/Subtype") == "/Image":
try: try:
raw = xobj.get_data() images = list(page.images)
if raw: except Exception:
compressed = _downsample_image(raw, quality, max_dim) continue
xobj._data = compressed for img_file in images:
if getattr(img_file, "indirect_reference", None) is None:
continue
try:
pil = img_file.image
if pil is None:
continue
prepared, save_kwargs = _prepare_image(
pil, quality=quality, max_dim=max_dim
)
img_file.replace(prepared, **save_kwargs)
replaced += 1
except Exception: except Exception:
pass pass
writer.add_page(page)
out_buf = io.BytesIO() out_buf = io.BytesIO()
writer.write_stream(out_buf) writer.write(out_buf)
return out_buf.getvalue() return out_buf.getvalue()