import os import subprocess from pathlib import Path import uuid from fastapi import HTTPException, status from actions.data.text import Text from schemas.process_document_schema import ProcessDocumentSchema # Caminho absoluto do executável do LibreOffice no Windows. # Usar caminho fixo evita problemas de PATH em serviços, venv e produção. SOFFICE_PATH = r"C:\Program Files\LibreOffice\program\soffice.exe" class ProcessDocumentAction: """ Action responsável por processar documentos editáveis. REGRAS IMPORTANTES: - TODO texto recebido vem COMPRIMIDO (RTF ou DOCX) - A descompressão acontece SEMPRE - Após descompressão: - DOCX → salvo diretamente - RTF → convertido para DOCX via LibreOffice A saída final é SEMPRE um arquivo DOCX em disco. """ # ====================================================== # MÉTODO PÚBLICO (ENTRYPOINT DA ACTION) # ====================================================== def execute(self, data: ProcessDocumentSchema) -> str: """ Orquestra todo o fluxo de processamento do documento. """ # 1) Prepara diretório e caminhos únicos diretorio = self._prepare_directory() rtf_path, docx_path = self._generate_paths(diretorio, data.id) # 2) Descompacta obrigatoriamente o conteúdo conteudo = self._decompress_content(data.texto) # Remove apenas espaços iniciais para detecção segura conteudo_strip = conteudo.lstrip() # 3) Se após descompressão for DOCX, salva direto if self._is_docx(conteudo_strip): self._save_docx(docx_path, conteudo) return docx_path.name # 4) Caso contrário, valida se é RTF self._validate_rtf(conteudo_strip) # 5) Normaliza e salva RTF (simples ou complexo) texto_rtf = conteudo_strip.strip() self._save_rtf(rtf_path, texto_rtf) # 6) Converte RTF → DOCX via LibreOffice self._convert_rtf_to_docx(diretorio, rtf_path) # 7) Valida resultado final self._validate_docx(docx_path) # 8) Retorno final return docx_path.name # ====================================================== # MÉTODOS PRIVADOS — PREPARAÇÃO # ====================================================== def _prepare_directory(self) -> Path: """ Garante que o diretório temporário exista. """ diretorio = Path("./storage/temp").resolve() diretorio.mkdir(parents=True, exist_ok=True) return diretorio def _generate_paths(self, diretorio: Path, document_id) -> tuple[Path, Path]: """ Gera nomes de arquivos únicos por execução, evitando conflitos e lock de arquivos no Windows. """ base_name = f"{document_id}_{uuid.uuid4().hex}" return ( diretorio / f"{base_name}.rtf", diretorio / f"{base_name}.docx", ) # ====================================================== # MÉTODOS PRIVADOS — CONTEÚDO # ====================================================== def _decompress_content(self, texto: bytes | None) -> str: """ Descompacta obrigatoriamente o conteúdo recebido. Caso não exista texto, cria um RTF vazio válido. """ if not texto: return r"{\rtf1\ansi\deff0\pard\par}" return Text.decompress(texto) or "" def _is_docx(self, conteudo: str) -> bool: """ DOCX é um arquivo ZIP e sempre inicia com PK\\x03\\x04. """ return conteudo.startswith("PK\x03\x04") def _validate_rtf(self, conteudo: str) -> None: """ Garante que o conteúdo seja um RTF válido. """ if not conteudo.startswith("{\\rtf"): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Conteúdo descompactado não é RTF nem DOCX válido", ) # ====================================================== # MÉTODOS PRIVADOS — ESCRITA DE ARQUIVOS # ====================================================== def _save_docx(self, path: Path, conteudo: str) -> None: """ Salva DOCX diretamente em disco. """ with open(path, "wb") as f: # DOCX descompactado ainda está em latin1 f.write(conteudo.encode("latin1")) def _save_rtf(self, path: Path, texto_rtf: str) -> None: """ Salva RTF em disco. Detecta automaticamente RTFs complexos (WPTools, tabelas, bookmarks, unicode) para escolher a forma correta de escrita. """ rtf_complexo = self._is_complex_rtf(texto_rtf) if rtf_complexo: # Escrita binária preserva estruturas complexas rtf_bytes = texto_rtf.encode("cp1252", errors="ignore") with open(path, "wb") as f: f.write(rtf_bytes) else: # Escrita textual para RTFs simples with open(path, "w", encoding="cp1252", errors="replace") as f: f.write(texto_rtf) def _is_complex_rtf(self, texto_rtf: str) -> bool: """ Detecta padrões comuns de RTFs complexos gerados por WPTools, OnlyOffice, Word, etc. """ return any( token in texto_rtf for token in ( "\\u", "{\\*\\bkmkstart", "\\trowd", "\\cellx", "\\wppr", ) ) # ====================================================== # MÉTODOS PRIVADOS — CONVERSÃO # ====================================================== def _convert_rtf_to_docx(self, diretorio: Path, rtf_path: Path) -> None: """ Executa o LibreOffice em modo headless para conversão RTF → DOCX. """ cmd = [ SOFFICE_PATH, "--headless", "--nologo", "--nolockcheck", "--nodefault", "--nofirststartwizard", "--convert-to", "docx", "--outdir", str(diretorio), str(rtf_path), ] clean_env = { "SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), "WINDIR": os.environ.get("WINDIR", ""), "PATH": os.environ.get("PATH", ""), "TEMP": os.environ.get("TEMP", ""), "TMP": os.environ.get("TMP", ""), } result = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=180, env=clean_env, ) if result.returncode != 0: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Erro ao converter RTF para DOCX via LibreOffice", ) # ====================================================== # MÉTODOS PRIVADOS — VALIDAÇÃO FINAL # ====================================================== def _validate_docx(self, docx_path: Path) -> None: """ Garante que o DOCX final realmente foi gerado. """ if not docx_path.exists(): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="DOCX não foi gerado pelo LibreOffice", )