pm-assistant / modules /pptx_generator.py
igorsteixeira
Robust PPTX import: retry, broader image detection, never lose slides
09dfe37
Raw
History Blame Contribute Delete
36.7 kB
"""
Gerador de apresentacoes PPTX com slides HTML renderizados (visual rico).
Pipeline: Claude gera HTML/CSS bonitos -> Playwright renderiza PNG -> monta PPTX com imagens.
"""
import asyncio
import gc
import io
import json
import logging
import os
import re
import tempfile
import time
from pptx import Presentation
from pptx.util import Inches, Emu
from modules.config import client_anthropic
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SLIDE_WIDTH_PX = 1920
_SLIDE_HEIGHT_PX = 1080
_CORA_BRAND = {
"pink": "#FE3E6D",
"pink_light": "#FF6B8A",
"dark": "#1A1A2E",
"dark2": "#16213E",
"gray": "#F5F5F7",
"text": "#2D2D2D",
"white": "#FFFFFF",
}
# ---------------------------------------------------------------------------
# System prompt — foco no RACIONAL das iniciativas por KR
# ---------------------------------------------------------------------------
_SYSTEM_PROMPT = """Voce e um designer de apresentacoes executivas de altissimo nivel.
Crie slides LINDOS em HTML/CSS para uma apresentacao da **Cora** (banco digital).
OBJETIVO DA APRESENTACAO:
- Mostrar o RACIONAL de priorizacao: para cada KR, explicar POR QUE as iniciativas foram priorizadas
- NAO mostrar roadmap/timeline — foque no raciocinio estrategico
- Tom executivo e direto
- FOCO EXCLUSIVO nas iniciativas relacionadas aos OKRs/KRs — NAO incluir temas regulatorios, auditoria, compliance ou sustentacao que nao estejam diretamente ligados a um KR
ESTRUTURA IDEAL:
1. Slide de capa impactante
2. Para cada KR relevante: 1-2 slides explicando:
- O KR apenas com nome e meta bruta (ex: "Reduzir FPR — meta: 0,22%"). NAO mostrar valor atual, NAO mostrar % de atingimento, NAO mostrar status (off-track/at-risk), NAO mostrar evolucao mensal. Apenas o NOME do KR e a META.
- As iniciativas priorizadas que impactam esse KR
- O MECANISMO de impacto (a logica de POR QUE a iniciativa ajuda)
- Exemplo: "Biometria → permite usuario desbloquear conta propria → reduz bloqueios indevidos de clientes bons"
3. OBRIGATORIO: Slide "Mapa de Correlacao — Iniciativas x Key Results" em formato de TABELA HTML pura:
- Fundo escuro (gradiente {dark} para {dark2}), texto branco
- Titulo grande no topo: "Mapa de Correlacao — Iniciativas x Key Results" com emoji 📊
- Subtitulo: "Como cada iniciativa contribui para os KRs. Impacto primario (●) e secundario (○) mapeados."
- Tabela com header: INICIATIVA | KR1 | KR2 | KR3 | SUSTENTACAO/COMPLIANCE
- Linhas agrupadas por CATEGORIA em negrito maiusculo (ex: "FRICCAO INTELIGENTE (KR1)")
- Cada iniciativa com emoji no inicio do nome
- Celulas: circulo rosa cheio (●) = impacto PRIMARIO, circulo rosa vazado (○) = impacto SECUNDARIO, vazio = sem impacto
- Bordas sutis entre linhas (rgba branco 10%)
- Rodape discreto "Cora | Risco e Seguranca"
4. Slide de encerramento
REGRAS DE DESIGN:
1. Cada slide e um HTML COMPLETO, self-contained, com CSS inline no <style> dentro do <head>
2. Viewport FIXO: {width}x{height}px (16:9 Full HD)
3. Brand Cora OBRIGATORIO:
- Rosa principal: {pink}
- Rosa claro: {pink_light}
- Fundo escuro: gradiente de {dark} para {dark2}
- Fundo alternativo: branco {white} com acentos rosa
- Texto claro em fundo escuro, texto escuro ({text}) em fundo branco
- Fonte: system-ui, -apple-system, 'Segoe UI', sans-serif
4. Alterne entre slides com fundo escuro e fundo claro para variedade visual
5. Use icones Unicode/emoji quando apropriado (📊 📈 🎯 ✅ 🔒 ⚡ 🚀 🛡️)
6. Para listas, use layout em GRID/CARDS — NAO use listas simples com bullets
7. Use setas (→) para mostrar cadeia de impacto
8. Adicione rodape discreto "Cora | Risco e Seguranca" em cada slide (exceto capa)
9. MAXIMO 3-4 topicos por slide — se tiver mais, quebre em slides adicionais
10. Textos CURTOS e impactantes — maximo 2 linhas por topico
REGRAS DE CONTEUDO:
1. Tudo em portugues brasileiro
2. NAO copie OKRs literalmente — reescreva de forma clara e executiva
3. NAO inclua datas ou timeline do roadmap
4. SEMPRE explique o MECANISMO: Iniciativa → Como funciona → Impacto no KR
5. PROIBIDO mostrar dados de acompanhamento: valor atual, % de atingimento, status (off-track/at-risk/on-track), gap analysis, evolucao mensal, comparativo mes a mes. Mostre APENAS nome do KR + meta bruta
6. NAO mencionar CPO, diretoria ou audiencia no conteudo dos slides
7. IGNORAR temas regulatorios, auditoria, compliance e sustentacao — focar APENAS nas iniciativas dos KRs
8. Seja especifico e concreto — nao generico
9. Se houver PRDs no contexto, use o OBJETIVO descrito no PRD para embasar o racional da iniciativa — o PRD detalha bem o proposito e impacto esperado de cada iniciativa. NAO copie o PRD, mas extraia a essencia do objetivo para explicar o POR QUE
FORMATO DE SAIDA — JSON puro, sem markdown, sem blocos de codigo:
[
{{
"titulo": "Titulo do slide",
"html": "<!DOCTYPE html><html>...</html>"
}}
]
IMPORTANTE:
- Retorne APENAS o JSON array, sem NENHUM texto antes ou depois
- O HTML deve ser COMPLETO (<!DOCTYPE html>, <html>, <head> com <style>, <body>)
- Cada slide deve ter width:{width}px e height:{height}px fixos no body (overflow:hidden)
- NAO use JavaScript — apenas HTML e CSS puro
- NAO use imagens externas ou Google Fonts — apenas system fonts e emoji
- Gere entre 8 e 15 slides dependendo da quantidade de conteudo"""
# ---------------------------------------------------------------------------
# 1. Generate HTML slides with Claude
# ---------------------------------------------------------------------------
def _gerar_slides_html(tema: str, contexto: str, instrucoes: str = "") -> list[dict]:
"""Usa Opus para gerar slides HTML bonitos."""
system = _SYSTEM_PROMPT.format(
width=_SLIDE_WIDTH_PX,
height=_SLIDE_HEIGHT_PX,
**_CORA_BRAND,
)
user_msg = f"TEMA DA APRESENTACAO: {tema}\n\n"
if contexto:
user_msg += f"CONTEXTO DISPONIVEL:\n{contexto}\n\n"
if instrucoes:
user_msg += f"INSTRUCOES ADICIONAIS: {instrucoes}\n\n"
user_msg += "Gere a apresentacao em JSON com slides HTML agora."
# Stream with auto-continuation (slides can be long)
resposta = ""
messages = [{"role": "user", "content": user_msg}]
max_continuacoes = 4
start = time.time()
for tentativa in range(max_continuacoes + 1):
if time.time() - start > 300:
break
chunks = []
stop_reason = None
try:
with client_anthropic.messages.stream(
model="claude-opus-4-6",
max_tokens=16384,
system=system,
messages=messages,
timeout=300,
) as stream:
for delta in stream.text_stream:
chunks.append(delta)
stop_reason = stream.get_final_message().stop_reason
except Exception as e:
logging.error(f"[PPTX Gen] Stream error: {e}")
if resposta:
break
raise
resposta += "".join(chunks)
if stop_reason != "max_tokens" or tentativa == max_continuacoes:
break
# Continue from where we left off
messages = [
{"role": "user", "content": user_msg},
{"role": "assistant", "content": resposta},
{"role": "user", "content": "Continue o JSON exatamente de onde parou. Nao repita o que ja foi escrito."},
]
# Parse JSON
resposta = resposta.strip()
if resposta.startswith("```"):
resposta = resposta.split("\n", 1)[1] if "\n" in resposta else resposta[3:]
if resposta.endswith("```"):
resposta = resposta[:-3]
resposta = resposta.strip()
def _sanitize_json(raw: str) -> str:
"""Fix invalid control characters inside JSON string values.
Replaces ALL control chars (including literal newlines/tabs inside strings)."""
def _escape_controls(m):
ch = m.group(0)
return {"\n": "\\n", "\r": "\\r", "\t": "\\t"}.get(ch, f"\\u{ord(ch):04x}")
return re.sub(r'[\x00-\x1f]', _escape_controls, raw)
# Strategy: try multiple approaches in order of reliability
parse_errors = []
# 1. Try strict=False first (handles control chars natively)
try:
slides = json.loads(resposta, strict=False)
return slides if isinstance(slides, list) and slides else None
except json.JSONDecodeError as e:
parse_errors.append(f"strict=False: {e}")
# 2. Try sanitized version
sanitized = _sanitize_json(resposta)
try:
slides = json.loads(sanitized)
return slides if isinstance(slides, list) and slides else None
except json.JSONDecodeError as e:
parse_errors.append(f"sanitized: {e}")
# 3. Try to fix truncated JSON
for fix in ["]", "}]", "\"}]", "\"\n}]", "\"\n }\n]"]:
try:
slides = json.loads(sanitized + fix)
logging.warning(f"[PPTX Gen] Fixed truncated JSON with: {fix}")
if isinstance(slides, list) and slides:
return slides
except json.JSONDecodeError:
continue
logging.error(f"[PPTX Gen] All JSON parse attempts failed: {parse_errors}")
logging.error(f"[PPTX Gen] Response (first 500): {resposta[:500]}")
raise ValueError(f"Nao foi possivel parsear o JSON dos slides: {parse_errors[-1]}")
# ---------------------------------------------------------------------------
# 2. Render HTML slides to PNG with Playwright
# ---------------------------------------------------------------------------
async def _renderizar_slides_async(slides: list[dict], tmp_dir: str) -> list[str]:
"""Renders each slide HTML to PNG using Playwright."""
from playwright.async_api import async_playwright
png_paths = []
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
)
page = await browser.new_page(
viewport={"width": _SLIDE_WIDTH_PX, "height": _SLIDE_HEIGHT_PX},
)
for i, slide in enumerate(slides):
html = slide.get("html", "")
if not html:
continue
html_path = os.path.join(tmp_dir, f"slide_{i:03d}.html")
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
await page.goto(f"file://{html_path}", wait_until="networkidle")
await page.wait_for_timeout(300)
png_path = os.path.join(tmp_dir, f"slide_{i:03d}.png")
await page.screenshot(path=png_path, type="png")
png_paths.append(png_path)
await browser.close()
return png_paths
def _renderizar_slides(slides: list[dict], tmp_dir: str) -> list[str]:
"""Sync wrapper for async Playwright rendering."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(
_renderizar_slides_async(slides, tmp_dir)
)
finally:
loop.close()
# ---------------------------------------------------------------------------
# 3. Mount PPTX with full-bleed PNG images
# ---------------------------------------------------------------------------
def _montar_pptx(png_paths: list[str]) -> bytes:
"""Creates PPTX with each slide being a full-bleed PNG image."""
prs = Presentation()
# 16:9 widescreen
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
for png_path in png_paths:
slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout
# Add image covering entire slide
slide.shapes.add_picture(
png_path,
left=Emu(0),
top=Emu(0),
width=prs.slide_width,
height=prs.slide_height,
)
bio = io.BytesIO()
prs.save(bio)
bio.seek(0)
del prs
gc.collect()
return bio.getvalue()
# ---------------------------------------------------------------------------
# PUBLIC API
# ---------------------------------------------------------------------------
def gerar_pptx(tema: str, contexto: str = "", instrucoes: str = "",
progress_callback=None) -> tuple[bytes, int]:
"""
Gera apresentacao PPTX com slides HTML renderizados (visual rico).
Pipeline:
1. Claude Opus gera HTML/CSS bonitos para cada slide
2. Playwright renderiza cada HTML em PNG (1920x1080)
3. Monta PPTX com imagens full-bleed
Args:
tema: tema/titulo da apresentacao
contexto: contexto carregado automaticamente (OKRs, roadmap, etc.)
instrucoes: instrucoes adicionais do usuario
progress_callback: funcao(step, total, msg)
Returns:
(bytes do PPTX, numero de slides)
"""
def _progress(step, total, msg):
if progress_callback:
progress_callback(step, total, msg)
# 1. Gerar HTML slides com Claude
_progress(1, 4, "Claude Opus esta criando os slides HTML com design visual...")
slides = _gerar_slides_html(tema, contexto, instrucoes)
n_slides = len(slides)
logging.info(f"[PPTX Gen] {n_slides} slides HTML gerados pelo Claude")
with tempfile.TemporaryDirectory(prefix="pptx_gen_") as tmp_dir:
# 2. Renderizar HTML -> PNG com Playwright
_progress(2, 4, f"Renderizando {n_slides} slides com Playwright...")
png_paths = _renderizar_slides(slides, tmp_dir)
logging.info(f"[PPTX Gen] {len(png_paths)} slides renderizados em PNG")
# 3. Montar PPTX
_progress(3, 4, f"Montando PPTX com {len(png_paths)} slides...")
pptx_bytes = _montar_pptx(png_paths)
# 4. Finalizar
_progress(4, 4, "Apresentacao pronta!")
gc.collect()
return pptx_bytes, n_slides, slides
def editar_slide(slides: list[dict], slide_idx: int, instrucao: str,
progress_callback=None) -> tuple[bytes, int, list[dict]]:
"""
Edita um slide especifico e remonta o PPTX.
Args:
slides: lista de slides (titulo + html) da geracao anterior
slide_idx: indice do slide a editar (0-based)
instrucao: o que o usuario quer mudar nesse slide
progress_callback: funcao(step, total, msg)
Returns:
(bytes do PPTX, numero de slides, slides atualizados)
"""
def _progress(step, total, msg):
if progress_callback:
progress_callback(step, total, msg)
slide_atual = slides[slide_idx]
_progress(1, 4, f"Editando slide {slide_idx + 1}: '{slide_atual.get('titulo', '')}'...")
system = f"""Voce e um designer de apresentacoes. Recebeu um slide HTML existente e precisa EDITA-LO conforme a instrucao do usuario.
REGRAS:
1. Mantenha o mesmo estilo visual (cores, fontes, layout geral) do slide original
2. Faca APENAS as alteracoes pedidas pelo usuario — nao mude o resto
3. O slide deve ter {_SLIDE_WIDTH_PX}x{_SLIDE_HEIGHT_PX}px fixos
4. Retorne APENAS o JSON do slide editado, sem texto antes ou depois
FORMATO DE SAIDA — JSON puro:
{{
"titulo": "Titulo do slide",
"html": "<!DOCTYPE html><html>...</html>"
}}"""
user_msg = f"""SLIDE ATUAL (indice {slide_idx}):
Titulo: {slide_atual.get('titulo', '')}
HTML: {slide_atual.get('html', '')}
INSTRUCAO DE EDICAO: {instrucao}
Retorne o slide editado em JSON."""
try:
resposta = ""
with client_anthropic.messages.stream(
model="claude-opus-4-6",
max_tokens=8192,
system=system,
messages=[{"role": "user", "content": user_msg}],
timeout=120,
) as stream:
for delta in stream.text_stream:
resposta += delta
except Exception as e:
logging.error(f"[PPTX Edit] Stream error: {e}")
raise
# Parse JSON
resposta = resposta.strip()
if resposta.startswith("```"):
resposta = resposta.split("\n", 1)[1] if "\n" in resposta else resposta[3:]
if resposta.endswith("```"):
resposta = resposta[:-3]
resposta = resposta.strip()
try:
novo_slide = json.loads(resposta, strict=False)
if isinstance(novo_slide, list) and novo_slide:
novo_slide = novo_slide[0]
except json.JSONDecodeError as e:
logging.error(f"[PPTX Edit] JSON parse error: {e}")
raise ValueError(f"Erro ao parsear slide editado: {e}")
# Substituir slide na lista
slides_atualizados = list(slides)
slides_atualizados[slide_idx] = novo_slide
logging.info(f"[PPTX Edit] Slide {slide_idx} editado: '{novo_slide.get('titulo', '')}'")
# Re-render e remontar
_progress(2, 4, f"Renderizando {len(slides_atualizados)} slides...")
with tempfile.TemporaryDirectory(prefix="pptx_edit_") as tmp_dir:
png_paths = _renderizar_slides(slides_atualizados, tmp_dir)
_progress(3, 4, "Montando PPTX...")
pptx_bytes = _montar_pptx(png_paths)
_progress(4, 4, "Edicao concluida!")
gc.collect()
return pptx_bytes, len(slides_atualizados), slides_atualizados
def _extrair_slides_mencionados(instrucao: str, total_slides: int) -> list[int]:
"""Extrai indices (0-based) dos slides mencionados na instrucao.
Se nenhum slide especifico for mencionado, retorna todos."""
# Match patterns like "Slide 1", "slide 3", "Slide 10"
matches = re.findall(r'[Ss]lide\s+(\d+)', instrucao)
if matches:
indices = set()
for m in matches:
num = int(m) - 1 # Convert 1-based to 0-based
if 0 <= num < total_slides:
indices.add(num)
return sorted(indices) if indices else list(range(total_slides))
# No specific slides mentioned — apply to all
return list(range(total_slides))
def editar_slides_lote(slides: list[dict], instrucao: str,
progress_callback=None) -> tuple[bytes, int, list[dict]]:
"""
Edita slides referenciados na instrucao, um por vez.
Slides nao mencionados sao mantidos intactos (sem reprocessar).
"""
def _progress(step, total, msg):
if progress_callback:
progress_callback(step, total, msg)
# Identificar quais slides precisam ser editados
indices_editar = _extrair_slides_mencionados(instrucao, len(slides))
logging.info(f"[PPTX Edit Lote] Slides a editar: {[i+1 for i in indices_editar]} de {len(slides)}")
slides_atualizados = list(slides) # copia — slides nao mencionados ficam intactos
total_steps = len(indices_editar) + 2 # edits + render + mount
step = 0
for idx in indices_editar:
step += 1
slide_atual = slides[idx]
titulo = slide_atual.get("titulo", f"Slide {idx+1}")
_progress(step, total_steps, f"Editando slide {idx+1}: '{titulo}'...")
system = f"""Voce e um designer de apresentacoes. Recebeu um slide HTML existente e precisa EDITA-LO conforme a instrucao do usuario.
REGRAS:
1. Mantenha o mesmo estilo visual (cores, fontes, layout geral) do slide original
2. Faca APENAS as alteracoes pedidas pelo usuario — nao mude o resto
3. O slide deve ter {_SLIDE_WIDTH_PX}x{_SLIDE_HEIGHT_PX}px fixos
4. Retorne APENAS o JSON do slide editado, sem texto antes ou depois
FORMATO DE SAIDA — JSON puro:
{{
"titulo": "Titulo do slide",
"html": "<!DOCTYPE html><html>...</html>"
}}"""
user_msg = f"""SLIDE ATUAL (Slide {idx+1}):
Titulo: {titulo}
HTML: {slide_atual.get('html', '')}
INSTRUCAO DE EDICAO (pode mencionar outros slides — aplique APENAS o que se refere ao Slide {idx+1}):
{instrucao}
Retorne o slide editado em JSON."""
try:
resposta = ""
with client_anthropic.messages.stream(
model="claude-opus-4-6",
max_tokens=8192,
system=system,
messages=[{"role": "user", "content": user_msg}],
timeout=120,
) as stream:
for delta in stream.text_stream:
resposta += delta
except Exception as e:
logging.error(f"[PPTX Edit Lote] Slide {idx+1} stream error: {e}")
continue # Manter slide original se falhar
# Parse JSON
resposta = resposta.strip()
if resposta.startswith("```"):
resposta = resposta.split("\n", 1)[1] if "\n" in resposta else resposta[3:]
if resposta.endswith("```"):
resposta = resposta[:-3]
resposta = resposta.strip()
try:
novo_slide = json.loads(resposta, strict=False)
if isinstance(novo_slide, list) and novo_slide:
novo_slide = novo_slide[0]
slides_atualizados[idx] = novo_slide
logging.info(f"[PPTX Edit Lote] Slide {idx+1} editado: '{novo_slide.get('titulo', '')}'")
except (json.JSONDecodeError, ValueError) as e:
logging.error(f"[PPTX Edit Lote] Slide {idx+1} JSON parse error: {e}")
# Manter slide original
# Re-render todos os slides (editados + nao editados)
step += 1
_progress(step, total_steps, f"Renderizando {len(slides_atualizados)} slides...")
with tempfile.TemporaryDirectory(prefix="pptx_edit_") as tmp_dir:
png_paths = _renderizar_slides(slides_atualizados, tmp_dir)
_progress(total_steps, total_steps, "Montando PPTX...")
pptx_bytes = _montar_pptx(png_paths)
gc.collect()
return pptx_bytes, len(slides_atualizados), slides_atualizados
# ---------------------------------------------------------------------------
# Drive persistence for presentations
# ---------------------------------------------------------------------------
def _get_apresentacoes_folder():
"""Get or create the 'apresentacoes' folder ID."""
from modules.drive_utils import get_folders
folders = get_folders()
if folders and folders.get("apresentacoes"):
return folders["apresentacoes"]
# Fallback: initialize structure to create folder
from modules.drive_utils import conectar_drive, drive_inicializar_estrutura
svc = conectar_drive()
if svc:
folders = drive_inicializar_estrutura(svc)
return folders.get("apresentacoes")
return None
def salvar_apresentacao_drive(slides: list[dict], nome: str) -> str | None:
"""Salva slides HTML como JSON no Drive (pasta apresentacoes)."""
from modules.drive_utils import (
conectar_drive_usuario, conectar_drive,
drive_upload_arquivo, drive_listar_arquivos, drive_atualizar_arquivo,
)
try:
folder_id = _get_apresentacoes_folder()
if not folder_id:
logging.warning("[PPTX Save] Pasta apresentacoes nao encontrada")
return None
# Sanitize name
import re as _re
safe_name = _re.sub(r'[^\w\s\-]', '', nome)[:50].strip() or "apresentacao"
file_name = f"pptx_{safe_name}.json"
payload = json.dumps({"nome": nome, "slides": slides}, ensure_ascii=False)
content_bytes = payload.encode("utf-8")
# Check if file already exists (update instead of duplicate)
svc_read = conectar_drive()
if svc_read:
existing = drive_listar_arquivos(svc_read, folder_id)
for arq in existing:
if arq["name"] == file_name:
drive_atualizar_arquivo(svc_read, arq["id"], content_bytes, "application/json")
logging.info(f"[PPTX Save] Apresentacao atualizada: {file_name}")
return arq["id"]
# Upload new
svc_write = conectar_drive_usuario() or svc_read
if svc_write:
fid = drive_upload_arquivo(svc_write, file_name, content_bytes,
"application/json", folder_id)
logging.info(f"[PPTX Save] Apresentacao salva: {file_name} (id={fid})")
return fid
except Exception as e:
logging.error(f"[PPTX Save] Erro ao salvar: {e}")
return None
def listar_apresentacoes_drive() -> list[dict]:
"""Lista apresentacoes salvas no Drive."""
from modules.drive_utils import conectar_drive, conectar_drive_usuario, drive_listar_arquivos
try:
folder_id = _get_apresentacoes_folder()
if not folder_id:
logging.warning("[PPTX List] Pasta apresentacoes nao encontrada")
return []
# Try user OAuth first (sees files created by user), then Service Account
svc = conectar_drive_usuario() or conectar_drive()
if not svc:
logging.warning("[PPTX List] Nenhum servico Drive disponivel")
return []
arquivos = drive_listar_arquivos(svc, folder_id)
logging.info(f"[PPTX List] Encontrados {len(arquivos)} arquivos na pasta apresentacoes")
# Accept both .json (editable) and .pptx (importable) files
result = [a for a in arquivos if not a.get("mimeType", "").startswith("application/vnd.google-apps.folder")]
return result
except Exception as e:
logging.error(f"[PPTX List] Erro: {e}")
return []
def carregar_apresentacao_drive(file_id: str, file_name: str = "") -> list[dict] | None:
"""Carrega slides HTML de uma apresentacao salva no Drive.
Se for .json, carrega direto. Se for .pptx ou Google Slides, extrai imagens e recria HTML via Claude.
"""
from modules.drive_utils import conectar_drive, conectar_drive_usuario
try:
logging.info(f"[PPTX Load] Iniciando carregamento: file_id={file_id}, file_name={file_name}")
svc = conectar_drive_usuario() or conectar_drive()
if not svc:
logging.error("[PPTX Load] Nenhum servico Drive disponivel")
return None
# Check mimeType to handle Google Slides vs binary files
logging.info("[PPTX Load] Buscando metadata do arquivo...")
file_meta = svc.files().get(fileId=file_id, fields="mimeType,name,size",
supportsAllDrives=True).execute()
mime = file_meta.get("mimeType", "")
name = file_meta.get("name", file_name)
size = file_meta.get("size", "?")
logging.info(f"[PPTX Load] Arquivo: {name} | mimeType: {mime} | size: {size}")
if mime == "application/vnd.google-apps.presentation":
# Google Slides native — export as PPTX
logging.info("[PPTX Load] Google Slides detectado, exportando como PPTX...")
raw = svc.files().export(
fileId=file_id,
mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation"
).execute()
logging.info(f"[PPTX Load] Export concluido: {len(raw) if raw else 0} bytes")
else:
# Regular file — download binary
logging.info("[PPTX Load] Baixando arquivo binario...")
raw = svc.files().get_media(fileId=file_id).execute()
logging.info(f"[PPTX Load] Download concluido: {len(raw) if raw else 0} bytes")
if not isinstance(raw, bytes):
raw = raw.encode("utf-8") if isinstance(raw, str) else raw
# JSON (app-generated) — load directly
if name.lower().endswith(".json") or mime == "application/json":
data = json.loads(raw.decode("utf-8"))
slides = data.get("slides", [])
logging.info(f"[PPTX Load] JSON carregado: {len(slides)} slides")
return slides
# Try JSON first (might not have .json extension)
try:
data = json.loads(raw.decode("utf-8"))
if "slides" in data:
slides = data["slides"]
logging.info(f"[PPTX Load] JSON detectado: {len(slides)} slides")
return slides
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# PPTX binary — extract images and recreate HTML
logging.info(f"[PPTX Load] Arquivo binario ({len(raw)} bytes), iniciando import PPTX...")
result = _importar_pptx(raw)
logging.info(f"[PPTX Load] Import retornou: {len(result) if result else 0} slides")
return result
except Exception as e:
logging.error(f"[PPTX Load] Erro: {e}", exc_info=True)
return None
def _importar_pptx(pptx_bytes: bytes) -> list[dict] | None:
"""Extrai conteudo de cada slide do PPTX e recria como HTML via Claude.
Tenta 2 abordagens por slide:
1. Se o slide tem imagem → envia imagem ao Claude (com retry)
2. Se o slide tem texto/formas → extrai texto e pede ao Claude recriar
Nunca pula slides — garante que todos os N slides aparecem no resultado.
"""
import base64
from pptx import Presentation as PptxPresentation
from modules.config import client_anthropic
def _extrair_imagem_slide(slide_obj) -> bytes | None:
"""Extrai a maior imagem de um slide (qualquer tipo de shape com .image)."""
imagens = []
for shape in slide_obj.shapes:
try:
# Tenta acessar .image em qualquer shape (nao apenas PICTURE)
if hasattr(shape, "image") and shape.image and shape.image.blob:
imagens.append(shape.image.blob)
except Exception:
pass
if not imagens:
return None
# Retorna a maior imagem (provavelmente o slide full-bleed)
return max(imagens, key=len)
def _recriar_via_imagem(img_data: bytes, slide_num: int) -> str | None:
"""Envia imagem ao Claude para recriar como HTML. Retry com backoff."""
img_b64 = base64.b64encode(img_data).decode("utf-8")
media_type = "image/png" if img_data[:4] == b'\x89PNG' else "image/jpeg"
for tentativa in range(3):
try:
resp = client_anthropic.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": img_b64},
},
{
"type": "text",
"text": (
"Recrie este slide EXATAMENTE como HTML/CSS. "
"Mantenha cores, layout, fontes, espacamentos e todo o conteudo textual identico. "
"Use as cores da marca Cora: pink #FE3E6D, dark #1A1A2E, dark2 #16213E. "
"O HTML deve ter exatamente 1920x1080px com fundo completo. "
"Retorne APENAS o HTML completo, sem explicacao, sem markdown, sem ```."
),
},
],
}],
timeout=120,
)
html = _limpar_html_resposta(resp.content[0].text)
if html and "<" in html:
return html
except Exception as e:
logging.warning(f"[PPTX Import] Slide {slide_num} imagem tentativa {tentativa+1}/3: {e}")
if tentativa < 2:
time.sleep(2 ** tentativa) # 1s, 2s
return None
def _placeholder_html(slide_num: int, msg: str = "") -> str:
"""Gera HTML placeholder para slides que nao puderam ser recriados."""
return f"""<!DOCTYPE html><html><head><style>
body {{ margin:0; width:1920px; height:1080px; display:flex; align-items:center;
justify-content:center; background:linear-gradient(135deg, #1A1A2E, #16213E);
font-family:system-ui,sans-serif; color:white; overflow:hidden; }}
.box {{ text-align:center; }}
h1 {{ font-size:48px; color:#FE3E6D; }}
p {{ font-size:24px; opacity:0.7; }}
</style></head><body><div class="box">
<h1>Slide {slide_num}</h1>
<p>{msg or "Conteudo nao extraivel — edite este slide manualmente"}</p>
</div></body></html>"""
try:
prs = PptxPresentation(io.BytesIO(pptx_bytes))
slides = []
total = len(prs.slides)
logging.info(f"[PPTX Import] Iniciando import de {total} slides")
for i, slide in enumerate(prs.slides):
slide_num = i + 1
logging.info(f"[PPTX Import] Processando slide {slide_num}/{total}")
# Strategy 1: Extract image (app-generated PPTX or Google Slides with images)
img_data = _extrair_imagem_slide(slide)
if img_data:
html = _recriar_via_imagem(img_data, slide_num)
if html:
slides.append({"titulo": f"Slide {slide_num}", "html": html})
logging.info(f"[PPTX Import] Slide {slide_num} recriado via imagem")
continue
# Imagem existia mas falhou 3x — cria placeholder
logging.warning(f"[PPTX Import] Slide {slide_num}: imagem falhou apos 3 tentativas")
slides.append({"titulo": f"Slide {slide_num}", "html": _placeholder_html(slide_num, "Imagem detectada mas nao foi possivel recriar")})
continue
# Strategy 2: Extract text content from shapes
textos = []
for shape in slide.shapes:
try:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
text = para.text.strip()
if text:
textos.append(text)
if shape.has_table:
table = shape.table
rows = []
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells]
rows.append(" | ".join(cells))
textos.append("TABELA:\n" + "\n".join(rows))
except Exception:
pass
if not textos:
logging.warning(f"[PPTX Import] Slide {slide_num}: sem conteudo extraivel")
slides.append({"titulo": f"Slide {slide_num}", "html": _placeholder_html(slide_num)})
continue
conteudo = "\n".join(textos)
logging.info(f"[PPTX Import] Slide {slide_num}: {len(textos)} blocos de texto extraidos")
try:
resp = client_anthropic.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{
"role": "user",
"content": (
f"Crie um slide HTML/CSS executivo e LINDO com o seguinte conteudo:\n\n"
f"{conteudo}\n\n"
f"REGRAS:\n"
f"- Cores da marca Cora: pink #FE3E6D, dark #1A1A2E, dark2 #16213E, gray #F5F5F7\n"
f"- Dimensoes exatas: 1920x1080px com fundo completo\n"
f"- Layout profissional, executivo, visual rico\n"
f"- Se houver TABELA, renderize como tabela HTML estilizada\n"
f"- Mantenha TODO o conteudo textual\n"
f"- Retorne APENAS o HTML completo, sem explicacao, sem markdown, sem ```."
),
}],
timeout=120,
)
html = _limpar_html_resposta(resp.content[0].text)
slides.append({"titulo": f"Slide {slide_num}", "html": html})
logging.info(f"[PPTX Import] Slide {slide_num} recriado via texto")
except Exception as e:
logging.error(f"[PPTX Import] Slide {slide_num} falhou: {e}")
slides.append({"titulo": f"Slide {slide_num}", "html": _placeholder_html(slide_num, "Erro ao recriar slide")})
logging.info(f"[PPTX Import] Total: {len(slides)} slides importados de {total}")
return slides if slides else None
except Exception as e:
logging.error(f"[PPTX Import] Erro geral: {e}", exc_info=True)
return None
def _limpar_html_resposta(text: str) -> str:
"""Remove markdown fences e whitespace de resposta do Claude."""
html = text.strip()
if html.startswith("```"):
html = re.sub(r'^```\w*\n?', '', html)
html = re.sub(r'\n?```$', '', html)
return html.strip()