""" 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 atualização incremental (incremental=True) para garantir que a assinatura estrutural, /ID do trailer, XMP Metadata e /OutputIntents originais permaneçam 100% intactos e conformes. """ from PIL import Image as PILImage from .compress import _prepare_image # A grande sacada: em vez de usar PdfWriter(clone_from=reader) # que reconstrói a árvore inteira do zero perdendo a conformidade, # instanciamos um writer que atua sobre um stream aberto no modo incremental. stream = io.BytesIO(data) writer = PdfWriter(stream, incremental=True) 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()