Files
pdf-toolkit/core/compress.py
CM Consultor e97adca143 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'
2026-08-09 05:01:16 +00:00

241 lines
6.8 KiB
Python

"""
Estratégias de compressão e otimização de PDF.
Usa pypdf (API oficial de ImageFile.replace) + Pillow para
re-amostragem de imagens. A abordagem antiga de gravar em
`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 logging
from typing import Optional, Tuple
from PIL import Image
from pypdf import PdfReader, PdfWriter
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(
img_bytes: bytes,
quality: int = 60,
max_dim: int = 2000,
fmt: str = "JPEG",
) -> bytes:
"""
Redimensiona e re-comprime bytes de imagem (helper legado / pdfa).
Aceita apenas containers que o Pillow abra (JPEG/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, save_kwargs = _prepare_image(img, quality=quality, max_dim=max_dim)
buf = io.BytesIO()
out_fmt = "JPEG" if fmt.upper() == "JPEG" else "PNG"
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:
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 embutidas.
Args:
data: Conteúdo do PDF original.
preset: Perfil de qualidade ("screen", "ebook", "printer", "prepress").
remove_unused: Tenta deduplicar objetos idênticos no writer.
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"])
quality = opts["quality"]
max_dim = opts["max_dim"]
reader = PdfReader(io.BytesIO(data))
# clone_from é obrigatório para ImageFile.replace() funcionar
writer = PdfWriter(clone_from=reader)
replaced = 0
errors = 0
for page in writer.pages:
try:
images = list(page.images)
except Exception as exc:
logger.debug("Falha ao listar imagens da página: %s", exc)
continue
for img_file in images:
# Imagens inline não têm indirect_reference — replace() recusa
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 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:
pass
out_buf = io.BytesIO()
writer.write(out_buf)
result = 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:
"""
Re-escreve o PDF (clone limpo). Não é linearização real de web-fast,
mas reestrutura o arquivo via pypdf.
"""
reader = PdfReader(io.BytesIO(data))
writer = PdfWriter(clone_from=reader)
out_buf = io.BytesIO()
writer.write(out_buf)
return out_buf.getvalue()