- 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'
174 lines
5.3 KiB
Python
174 lines
5.3 KiB
Python
"""
|
|
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_pdf (ImageFile.replace)
|
|
mas preserva metadados XMP e OutputIntents.
|
|
"""
|
|
from PIL import Image as PILImage
|
|
from .compress import _prepare_image
|
|
|
|
reader = PdfReader(io.BytesIO(data))
|
|
writer = PdfWriter(clone_from=reader)
|
|
|
|
replaced = 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:
|
|
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:
|
|
pass
|
|
|
|
out_buf = io.BytesIO()
|
|
writer.write(out_buf)
|
|
return out_buf.getvalue() |