saas_api/actions/data/process_document_action.py

221 lines
7.3 KiB
Python

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",
)